Own the workspace list in MultiWorkspace (#52546)

Mikayla Maki and Max Brunsfeld created

## Context

TODO

## Self-Review Checklist

<!-- Check before requesting review: -->
- [ ] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [ ] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [ ] Tests cover the new/changed behavior
- [ ] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>

Change summary

crates/agent_ui/src/agent_panel.rs                    |  19 
crates/agent_ui/src/conversation_view.rs              |   2 
crates/git_ui/src/worktree_picker.rs                  |  33 
crates/project_panel/src/project_panel.rs             |   6 
crates/recent_projects/src/disconnected_overlay.rs    |   2 
crates/recent_projects/src/recent_projects.rs         |  66 +-
crates/recent_projects/src/remote_connections.rs      |   8 
crates/recent_projects/src/remote_servers.rs          |   4 
crates/recent_projects/src/sidebar_recent_projects.rs |   8 
crates/recent_projects/src/wsl_picker.rs              |  10 
crates/sidebar/src/sidebar.rs                         |  54 -
crates/sidebar/src/sidebar_tests.rs                   |  48 +
crates/workspace/src/multi_workspace.rs               | 321 +++++++++---
crates/workspace/src/persistence.rs                   |  20 
crates/workspace/src/welcome.rs                       |   4 
crates/workspace/src/workspace.rs                     | 142 +++-
crates/zed/src/visual_test_runner.rs                  |   5 
crates/zed/src/zed.rs                                 |  31 
crates/zed/src/zed/open_listener.rs                   |   4 
19 files changed, 512 insertions(+), 275 deletions(-)

Detailed changes

crates/agent_ui/src/agent_panel.rs 🔗

@@ -83,8 +83,9 @@ use ui::{
 };
 use util::{ResultExt as _, debug_panic};
 use workspace::{
-    CollaboratorId, DraggedSelection, DraggedTab, OpenResult, PathList, SerializedPathList,
-    ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId,
+    CollaboratorId, DraggedSelection, DraggedTab, OpenMode, OpenResult, PathList,
+    SerializedPathList, ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace,
+    WorkspaceId,
     dock::{DockPosition, Panel, PanelEvent},
 };
 use zed_actions::{
@@ -2939,7 +2940,15 @@ impl AgentPanel {
             ..
         } = cx
             .update(|_window, cx| {
-                Workspace::new_local(all_paths, app_state, window_handle, None, None, false, cx)
+                Workspace::new_local(
+                    all_paths,
+                    app_state,
+                    window_handle,
+                    None,
+                    None,
+                    OpenMode::Add,
+                    cx,
+                )
             })?
             .await?;
 
@@ -3062,8 +3071,8 @@ impl AgentPanel {
             });
         })?;
 
-        new_window_handle.update(cx, |multi_workspace, _window, cx| {
-            multi_workspace.activate(new_workspace.clone(), cx);
+        new_window_handle.update(cx, |multi_workspace, window, cx| {
+            multi_workspace.activate(new_workspace.clone(), window, cx);
         })?;
 
         this.update_in(cx, |this, window, cx| {

crates/agent_ui/src/conversation_view.rs 🔗

@@ -2413,7 +2413,7 @@ impl ConversationView {
                                     .update(cx, |multi_workspace, window, cx| {
                                         window.activate_window();
                                         if let Some(workspace) = workspace_handle.upgrade() {
-                                            multi_workspace.activate(workspace.clone(), cx);
+                                            multi_workspace.activate(workspace.clone(), window, cx);
                                             workspace.update(cx, |workspace, cx| {
                                                 workspace.focus_panel::<AgentPanel>(window, cx);
                                             });

crates/git_ui/src/worktree_picker.rs 🔗

@@ -20,7 +20,9 @@ use settings::Settings;
 use std::{path::PathBuf, sync::Arc};
 use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
 use util::{ResultExt, debug_panic};
-use workspace::{ModalView, MultiWorkspace, Workspace, notifications::DetachAndPromptErr};
+use workspace::{
+    ModalView, MultiWorkspace, OpenMode, Workspace, notifications::DetachAndPromptErr,
+};
 
 use crate::git_panel::show_error_toast;
 
@@ -354,7 +356,7 @@ impl WorktreeListDelegate {
                 workspace
                     .update_in(cx, |workspace, window, cx| {
                         workspace.open_workspace_for_paths(
-                            replace_current_window,
+                            OpenMode::Replace,
                             vec![new_worktree_path],
                             window,
                             cx,
@@ -407,10 +409,15 @@ impl WorktreeListDelegate {
         else {
             return;
         };
+        let open_mode = if replace_current_window {
+            OpenMode::Replace
+        } else {
+            OpenMode::NewWindow
+        };
 
         if is_local {
             let open_task = workspace.update(cx, |workspace, cx| {
-                workspace.open_workspace_for_paths(replace_current_window, vec![path], window, cx)
+                workspace.open_workspace_for_paths(open_mode, vec![path], window, cx)
             });
             cx.spawn(async move |_, _| {
                 open_task?.await?;
@@ -951,16 +958,6 @@ impl PickerDelegate for WorktreeListDelegate {
                     })
                     .child(
                         Button::new("open-in-new-window", "Open in New Window")
-                            .key_binding(
-                                KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
-                                    .map(|kb| kb.size(rems_from_px(12.))),
-                            )
-                            .on_click(|_, window, cx| {
-                                window.dispatch_action(menu::Confirm.boxed_clone(), cx)
-                            }),
-                    )
-                    .child(
-                        Button::new("open-in-window", "Open")
                             .key_binding(
                                 KeyBinding::for_action_in(
                                     &menu::SecondaryConfirm,
@@ -973,6 +970,16 @@ impl PickerDelegate for WorktreeListDelegate {
                                 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
                             }),
                     )
+                    .child(
+                        Button::new("open-in-window", "Open")
+                            .key_binding(
+                                KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
+                                    .map(|kb| kb.size(rems_from_px(12.))),
+                            )
+                            .on_click(|_, window, cx| {
+                                window.dispatch_action(menu::Confirm.boxed_clone(), cx)
+                            }),
+                    )
                     .into_any(),
             )
         }

crates/project_panel/src/project_panel.rs 🔗

@@ -71,8 +71,8 @@ use util::{
     rel_path::{RelPath, RelPathBuf},
 };
 use workspace::{
-    DraggedSelection, OpenInTerminal, OpenOptions, OpenVisible, PreviewTabsSettings, SelectedEntry,
-    SplitDirection, Workspace,
+    DraggedSelection, OpenInTerminal, OpenMode, OpenOptions, OpenVisible, PreviewTabsSettings,
+    SelectedEntry, SplitDirection, Workspace,
     dock::{DockPosition, Panel, PanelEvent},
     notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt},
 };
@@ -7126,7 +7126,7 @@ impl Render for ProjectPanel {
                                     .workspace
                                     .update(cx, |workspace, cx| {
                                         workspace.open_workspace_for_paths(
-                                            true,
+                                            OpenMode::Replace,
                                             external_paths.paths().to_owned(),
                                             window,
                                             cx,

crates/recent_projects/src/recent_projects.rs 🔗

@@ -46,7 +46,7 @@ use ui::{
 };
 use util::{ResultExt, paths::PathExt};
 use workspace::{
-    HistoryManager, ModalView, MultiWorkspace, OpenOptions, OpenVisible, PathList,
+    HistoryManager, ModalView, MultiWorkspace, OpenMode, OpenOptions, OpenVisible, PathList,
     SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId,
     notifications::DetachAndPromptErr, with_active_or_new_workspace,
 };
@@ -262,13 +262,13 @@ pub fn init(cx: &mut App) {
                         user: None,
                     });
 
-                    let replace_window = match create_new_window {
+                    let requesting_window = match create_new_window {
                         false => window_handle,
                         true => None,
                     };
 
                     let open_options = workspace::OpenOptions {
-                        replace_window,
+                        requesting_window,
                         ..Default::default()
                     };
 
@@ -321,7 +321,7 @@ pub fn init(cx: &mut App) {
             let fs = workspace.project().read(cx).fs().clone();
             add_wsl_distro(fs, &open_wsl.distro, cx);
             let open_options = OpenOptions {
-                replace_window: window.window_handle().downcast::<MultiWorkspace>(),
+                requesting_window: window.window_handle().downcast::<MultiWorkspace>(),
                 ..Default::default()
             };
 
@@ -1031,14 +1031,14 @@ impl PickerDelegate for RecentProjectsDelegate {
                 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
                     cx.defer(move |cx| {
                         handle
-                            .update(cx, |multi_workspace, _window, cx| {
+                            .update(cx, |multi_workspace, window, cx| {
                                 let workspace = multi_workspace
                                     .workspaces()
                                     .iter()
                                     .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
                                     .cloned();
                                 if let Some(workspace) = workspace {
-                                    multi_workspace.activate(workspace, cx);
+                                    multi_workspace.activate(workspace, window, cx);
                                 }
                             })
                             .log_err();
@@ -1079,7 +1079,12 @@ impl PickerDelegate for RecentProjectsDelegate {
                                     cx.defer(move |cx| {
                                         if let Some(task) = handle
                                             .update(cx, |multi_workspace, window, cx| {
-                                                multi_workspace.open_project(paths, window, cx)
+                                                multi_workspace.open_project(
+                                                    paths,
+                                                    OpenMode::Replace,
+                                                    window,
+                                                    cx,
+                                                )
                                             })
                                             .log_err()
                                         {
@@ -1090,7 +1095,12 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 return;
                             } else {
                                 workspace
-                                    .open_workspace_for_paths(false, paths, window, cx)
+                                    .open_workspace_for_paths(
+                                        OpenMode::NewWindow,
+                                        paths,
+                                        window,
+                                        cx,
+                                    )
                                     .detach_and_prompt_err(
                                         "Failed to open project",
                                         window,
@@ -1107,7 +1117,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 None
                             };
                             let open_options = OpenOptions {
-                                replace_window,
+                                requesting_window: replace_window,
                                 ..Default::default()
                             };
                             if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
@@ -1804,12 +1814,13 @@ impl RecentProjectsDelegate {
             cx.defer(move |cx| {
                 handle
                     .update(cx, |multi_workspace, window, cx| {
-                        let index = multi_workspace
+                        let workspace = multi_workspace
                             .workspaces()
                             .iter()
-                            .position(|ws| ws.read(cx).database_id() == Some(workspace_id));
-                        if let Some(index) = index {
-                            multi_workspace.remove_workspace(index, window, cx);
+                            .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
+                            .cloned();
+                        if let Some(workspace) = workspace {
+                            multi_workspace.remove(&workspace, window, cx);
                         }
                     })
                     .log_err();
@@ -1886,7 +1897,7 @@ mod tests {
     use super::*;
 
     #[gpui::test]
-    async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
+    async fn test_dirty_workspace_replaced_when_opening_recent_project(cx: &mut TestAppContext) {
         let app_state = init_test(cx);
 
         cx.update(|cx| {
@@ -1995,6 +2006,15 @@ 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.
+        assert!(
+            cx.has_pending_prompt(),
+            "Should prompt to save dirty buffer before replacing workspace"
+        );
+        cx.simulate_prompt_answer("Don't Save");
+        cx.run_until_parked();
+
         multi_workspace
             .update(cx, |multi_workspace, _, cx| {
                 assert!(
@@ -2007,26 +2027,16 @@ mod tests {
                 );
 
                 assert!(
-                    multi_workspace.workspaces().len() >= 2,
-                    "Should have at least 2 workspaces: the dirty one and the newly opened one"
+                    !multi_workspace.workspaces().contains(&dirty_workspace),
+                    "The original dirty workspace should have been replaced"
                 );
 
                 assert!(
-                    multi_workspace.workspaces().contains(&dirty_workspace),
-                    "The original dirty workspace should still be present"
-                );
-
-                assert!(
-                    dirty_workspace.read(cx).is_edited(),
-                    "The original workspace should still be dirty"
+                    !multi_workspace.workspace().read(cx).is_edited(),
+                    "The active workspace should be the freshly opened one, not dirty"
                 );
             })
             .unwrap();
-
-        assert!(
-            !cx.has_pending_prompt(),
-            "No save prompt in multi-workspace mode — dirty workspace survives in background"
-        );
     }
 
     fn open_recent_projects(

crates/recent_projects/src/remote_connections.rs 🔗

@@ -132,7 +132,7 @@ pub async fn open_remote_project(
     open_options: workspace::OpenOptions,
     cx: &mut AsyncApp,
 ) -> Result<()> {
-    let created_new_window = open_options.replace_window.is_none();
+    let created_new_window = open_options.requesting_window.is_none();
 
     let (existing, open_visible) = find_existing_workspace(
         &paths,
@@ -159,7 +159,7 @@ pub async fn open_remote_project(
             let open_results = existing_window
                 .update(cx, |multi_workspace, window, cx| {
                     window.activate_window();
-                    multi_workspace.activate(existing_workspace.clone(), cx);
+                    multi_workspace.activate(existing_workspace.clone(), window, cx);
                     existing_workspace.update(cx, |workspace, cx| {
                         workspace.open_paths(
                             resolved_paths,
@@ -201,7 +201,7 @@ pub async fn open_remote_project(
         );
     }
 
-    let (window, initial_workspace) = if let Some(window) = open_options.replace_window {
+    let (window, initial_workspace) = if let Some(window) = open_options.requesting_window {
         let workspace = window.update(cx, |multi_workspace, _, _| {
             multi_workspace.workspace().clone()
         })?;
@@ -854,7 +854,7 @@ mod tests {
             paths,
             app_state,
             workspace::OpenOptions {
-                replace_window: Some(window),
+                requesting_window: Some(window),
                 ..Default::default()
             },
             &mut async_cx,

crates/recent_projects/src/remote_servers.rs 🔗

@@ -1566,7 +1566,7 @@ impl RemoteServerProjects {
                         project.paths.into_iter().map(PathBuf::from).collect(),
                         app_state,
                         OpenOptions {
-                            replace_window,
+                            requesting_window: replace_window,
                             ..OpenOptions::default()
                         },
                         cx,
@@ -1894,7 +1894,7 @@ impl RemoteServerProjects {
                 vec![starting_dir].into_iter().map(PathBuf::from).collect(),
                 app_state,
                 OpenOptions {
-                    replace_window,
+                    requesting_window: replace_window,
                     ..OpenOptions::default()
                 },
                 cx,

crates/recent_projects/src/sidebar_recent_projects.rs 🔗

@@ -17,8 +17,8 @@ use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
 use ui_input::ErasedEditor;
 use util::{ResultExt, paths::PathExt};
 use workspace::{
-    MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceDb,
-    WorkspaceId, notifications::DetachAndPromptErr,
+    MultiWorkspace, OpenMode, OpenOptions, PathList, SerializedWorkspaceLocation, Workspace,
+    WorkspaceDb, WorkspaceId, notifications::DetachAndPromptErr,
 };
 
 use crate::{highlights_for_path, icon_for_remote_connection, open_remote_project};
@@ -272,7 +272,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
                     cx.defer(move |cx| {
                         if let Some(task) = handle
                             .update(cx, |multi_workspace, window, cx| {
-                                multi_workspace.open_project(paths, window, cx)
+                                multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
                             })
                             .log_err()
                         {
@@ -287,7 +287,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
                     let app_state = workspace.app_state().clone();
                     let replace_window = window.window_handle().downcast::<MultiWorkspace>();
                     let open_options = OpenOptions {
-                        replace_window,
+                        requesting_window: replace_window,
                         ..Default::default()
                     };
                     if let RemoteConnectionOptions::Ssh(connection) = &mut connection {

crates/recent_projects/src/wsl_picker.rs 🔗

@@ -245,14 +245,16 @@ impl WslOpenModal {
             true => secondary,
             false => !secondary,
         };
-        let replace_window = match replace_current_window {
-            true => window.window_handle().downcast::<MultiWorkspace>(),
-            false => None,
+        let open_mode = if replace_current_window {
+            workspace::OpenMode::Replace
+        } else {
+            workspace::OpenMode::NewWindow
         };
 
         let paths = self.paths.clone();
         let open_options = workspace::OpenOptions {
-            replace_window,
+            requesting_window: window.window_handle().downcast::<MultiWorkspace>(),
+            open_mode,
             ..Default::default()
         };
 

crates/sidebar/src/sidebar.rs 🔗

@@ -1334,8 +1334,11 @@ impl Sidebar {
                                     this.focused_thread = None;
                                     if let Some(multi_workspace) = this.multi_workspace.upgrade() {
                                         multi_workspace.update(cx, |multi_workspace, cx| {
-                                            multi_workspace
-                                                .activate(workspace_for_open.clone(), cx);
+                                            multi_workspace.activate(
+                                                workspace_for_open.clone(),
+                                                window,
+                                                cx,
+                                            );
                                         });
                                     }
                                     if AgentPanel::is_visible(&workspace_for_open, cx) {
@@ -1438,13 +1441,7 @@ impl Sidebar {
                                 if let Some(mw) = multi_workspace_for_worktree.upgrade() {
                                     let ws = workspace_for_remove_worktree.clone();
                                     mw.update(cx, |multi_workspace, cx| {
-                                        if let Some(index) = multi_workspace
-                                            .workspaces()
-                                            .iter()
-                                            .position(|w| *w == ws)
-                                        {
-                                            multi_workspace.remove_workspace(index, window, cx);
-                                        }
+                                        multi_workspace.remove(&ws, window, cx);
                                     });
                                 }
                             } else {
@@ -1474,7 +1471,7 @@ impl Sidebar {
                         move |window, cx| {
                             if let Some(mw) = multi_workspace_for_add.upgrade() {
                                 mw.update(cx, |mw, cx| {
-                                    mw.activate(workspace_for_add.clone(), cx);
+                                    mw.activate(workspace_for_add.clone(), window, cx);
                                 });
                             }
                             workspace_for_add.update(cx, |workspace, cx| {
@@ -1497,14 +1494,11 @@ impl Sidebar {
                             move |window, cx| {
                                 if let Some(mw) = multi_workspace_for_move.upgrade() {
                                     mw.update(cx, |multi_workspace, cx| {
-                                        if let Some(index) = multi_workspace
-                                            .workspaces()
-                                            .iter()
-                                            .position(|w| *w == workspace_for_move)
-                                        {
-                                            multi_workspace
-                                                .move_workspace_to_new_window(index, window, cx);
-                                        }
+                                        multi_workspace.move_workspace_to_new_window(
+                                            &workspace_for_move,
+                                            window,
+                                            cx,
+                                        );
                                     });
                                 }
                             },
@@ -1520,11 +1514,7 @@ impl Sidebar {
                             if let Some(mw) = multi_workspace_for_remove.upgrade() {
                                 let ws = workspace_for_remove.clone();
                                 mw.update(cx, |multi_workspace, cx| {
-                                    if let Some(index) =
-                                        multi_workspace.workspaces().iter().position(|w| *w == ws)
-                                    {
-                                        multi_workspace.remove_workspace(index, window, cx);
-                                    }
+                                    multi_workspace.remove(&ws, window, cx);
                                 });
                             }
                         })
@@ -1942,7 +1932,7 @@ impl Sidebar {
         self.record_thread_access(&metadata.session_id);
 
         multi_workspace.update(cx, |multi_workspace, cx| {
-            multi_workspace.activate(workspace.clone(), cx);
+            multi_workspace.activate(workspace.clone(), window, cx);
         });
 
         Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
@@ -1962,7 +1952,7 @@ impl Sidebar {
         let activated = target_window
             .update(cx, |multi_workspace, window, cx| {
                 window.activate_window();
-                multi_workspace.activate(workspace.clone(), cx);
+                multi_workspace.activate(workspace.clone(), window, cx);
                 Self::load_agent_thread_in_workspace(&workspace, &metadata, true, window, cx);
             })
             .log_err()
@@ -2024,7 +2014,9 @@ impl Sidebar {
         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, window, cx));
+        let open_task = multi_workspace.update(cx, |mw, cx| {
+            mw.open_project(paths, workspace::OpenMode::Activate, window, cx)
+        });
 
         cx.spawn_in(window, async move |this, cx| {
             let workspace = open_task.await?;
@@ -2512,7 +2504,7 @@ impl Sidebar {
                 } => {
                     if let Some(mw) = weak_multi_workspace.upgrade() {
                         mw.update(cx, |mw, cx| {
-                            mw.activate(workspace.clone(), cx);
+                            mw.activate(workspace.clone(), window, cx);
                         });
                     }
                     this.focused_thread = Some(metadata.session_id.clone());
@@ -2527,7 +2519,7 @@ impl Sidebar {
                 } => {
                     if let Some(mw) = weak_multi_workspace.upgrade() {
                         mw.update(cx, |mw, cx| {
-                            mw.activate(workspace.clone(), cx);
+                            mw.activate(workspace.clone(), window, cx);
                         });
                     }
                     this.record_thread_access(&metadata.session_id);
@@ -2543,7 +2535,7 @@ impl Sidebar {
                     if let Some(mw) = weak_multi_workspace.upgrade() {
                         if let Some(original_ws) = &original_workspace {
                             mw.update(cx, |mw, cx| {
-                                mw.activate(original_ws.clone(), cx);
+                                mw.activate(original_ws.clone(), window, cx);
                             });
                         }
                     }
@@ -2594,7 +2586,7 @@ impl Sidebar {
         if let Some((metadata, workspace)) = initial_preview {
             if let Some(mw) = self.multi_workspace.upgrade() {
                 mw.update(cx, |mw, cx| {
-                    mw.activate(workspace.clone(), cx);
+                    mw.activate(workspace.clone(), window, cx);
                 });
             }
             self.focused_thread = Some(metadata.session_id.clone());
@@ -2895,7 +2887,7 @@ impl Sidebar {
         self.focused_thread = None;
 
         multi_workspace.update(cx, |multi_workspace, cx| {
-            multi_workspace.activate(workspace.clone(), cx);
+            multi_workspace.activate(workspace.clone(), window, cx);
         });
 
         workspace.update(cx, |workspace, cx| {

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -385,7 +385,8 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
 
     // Remove the second workspace
     multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.remove_workspace(1, window, cx);
+        let workspace = mw.workspaces()[1].clone();
+        mw.remove(&workspace, window, cx);
     });
     cx.run_until_parked();
 
@@ -1737,7 +1738,8 @@ async fn test_confirm_on_historical_thread_activates_workspace(cx: &mut TestAppC
 
     // Switch to workspace 1 so we can verify the confirm switches back.
     multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.activate_index(1, window, cx);
+        let workspace = mw.workspaces()[1].clone();
+        mw.activate(workspace, window, cx);
     });
     cx.run_until_parked();
     assert_eq!(
@@ -2001,7 +2003,8 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
     });
 
     multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.activate_index(0, window, cx);
+        let workspace = mw.workspaces()[0].clone();
+        mw.activate(workspace, window, cx);
     });
     cx.run_until_parked();
 
@@ -2056,7 +2059,8 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
     // a workspace header) should clear focused_thread.
     multi_workspace.update_in(cx, |mw, window, cx| {
         if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) {
-            mw.activate_index(index, window, cx);
+            let workspace = mw.workspaces()[index].clone();
+            mw.activate(workspace, window, cx);
         }
     });
     cx.run_until_parked();
@@ -2317,7 +2321,8 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
 
     // Switch to the worktree workspace.
     multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.activate_index(1, window, cx);
+        let workspace = mw.workspaces()[1].clone();
+        mw.activate(workspace, window, cx);
     });
 
     let sidebar = setup_sidebar(&multi_workspace, cx);
@@ -2887,7 +2892,8 @@ async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAp
 
     // Switch back to the main workspace before setting up the sidebar.
     multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.activate_index(0, window, cx);
+        let workspace = mw.workspaces()[0].clone();
+        mw.activate(workspace, window, cx);
     });
 
     let sidebar = setup_sidebar(&multi_workspace, cx);
@@ -2993,7 +2999,8 @@ async fn test_absorbed_worktree_completion_triggers_notification(cx: &mut TestAp
     let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
 
     multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.activate_index(0, window, cx);
+        let workspace = mw.workspaces()[0].clone();
+        mw.activate(workspace, window, cx);
     });
 
     let sidebar = setup_sidebar(&multi_workspace, cx);
@@ -3338,7 +3345,8 @@ async fn test_clicking_absorbed_worktree_thread_activates_worktree_workspace(
 
     // Activate the main workspace before setting up the sidebar.
     multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.activate_index(0, window, cx);
+        let workspace = mw.workspaces()[0].clone();
+        mw.activate(workspace, window, cx);
     });
 
     let sidebar = setup_sidebar(&multi_workspace, cx);
@@ -3422,7 +3430,8 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works
 
     // Ensure workspace A is active.
     multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.activate_index(0, window, cx);
+        let workspace = mw.workspaces()[0].clone();
+        mw.activate(workspace, window, cx);
     });
     cx.run_until_parked();
     assert_eq!(
@@ -3484,7 +3493,8 @@ async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace(
 
     // Start with workspace A active.
     multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.activate_index(0, window, cx);
+        let workspace = mw.workspaces()[0].clone();
+        mw.activate(workspace, window, cx);
     });
     cx.run_until_parked();
     assert_eq!(
@@ -3545,7 +3555,8 @@ async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace(
 
     // Activate workspace B (index 1) to make it the active one.
     multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.activate_index(1, window, cx);
+        let workspace = mw.workspaces()[1].clone();
+        mw.activate(workspace, window, cx);
     });
     cx.run_until_parked();
     assert_eq!(
@@ -3928,7 +3939,8 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon
 
     // Activate main workspace so the sidebar tracks the main panel.
     multi_workspace.update_in(cx, |mw, window, cx| {
-        mw.activate_index(0, window, cx);
+        let workspace = mw.workspaces()[0].clone();
+        mw.activate(workspace, window, cx);
     });
 
     let sidebar = setup_sidebar(&multi_workspace, cx);
@@ -4784,9 +4796,11 @@ mod property_test {
                 state.workspace_paths.push(worktree.path);
             }
             Operation::RemoveWorkspace { index } => {
-                let removed = multi_workspace
-                    .update_in(cx, |mw, window, cx| mw.remove_workspace(index, window, cx));
-                if removed.is_some() {
+                let removed = multi_workspace.update_in(cx, |mw, window, cx| {
+                    let workspace = mw.workspaces()[index].clone();
+                    mw.remove(&workspace, window, cx)
+                });
+                if removed {
                     state.workspace_paths.remove(index);
                     state.main_repo_indices.retain(|i| *i != index);
                     for i in &mut state.main_repo_indices {
@@ -4799,8 +4813,8 @@ mod property_test {
             Operation::SwitchWorkspace { index } => {
                 let workspace =
                     multi_workspace.read_with(cx, |mw, _| mw.workspaces()[index].clone());
-                multi_workspace.update_in(cx, |mw, _window, cx| {
-                    mw.activate(workspace, cx);
+                multi_workspace.update_in(cx, |mw, window, cx| {
+                    mw.activate(workspace, window, cx);
                 });
             }
             Operation::AddLinkedWorktree { workspace_index } => {

crates/workspace/src/multi_workspace.rs 🔗

@@ -24,8 +24,8 @@ use ui::{ContextMenu, right_click_menu};
 const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
 
 use crate::{
-    CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel,
-    Workspace, WorkspaceId, client_side_decorations,
+    CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, OpenMode,
+    Panel, Workspace, WorkspaceId, client_side_decorations,
 };
 
 actions!(
@@ -233,7 +233,7 @@ impl MultiWorkspace {
                     this.close_sidebar(window, cx);
                 }
             });
-        Self::subscribe_to_workspace(&workspace, cx);
+        Self::subscribe_to_workspace(&workspace, window, cx);
         let weak_self = cx.weak_entity();
         workspace.update(cx, |workspace, cx| {
             workspace.set_multi_workspace(weak_self, cx);
@@ -398,10 +398,14 @@ impl MultiWorkspace {
         .detach_and_log_err(cx);
     }
 
-    fn subscribe_to_workspace(workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
-        cx.subscribe(workspace, |this, workspace, event, cx| {
+    fn subscribe_to_workspace(
+        workspace: &Entity<Workspace>,
+        window: &Window,
+        cx: &mut Context<Self>,
+    ) {
+        cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| {
             if let WorkspaceEvent::Activate = event {
-                this.activate(workspace, cx);
+                this.activate(workspace.clone(), window, cx);
             }
         })
         .detach();
@@ -419,54 +423,107 @@ impl MultiWorkspace {
         self.active_workspace_index
     }
 
-    pub fn activate(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
+    /// Adds a workspace to this window without changing which workspace is
+    /// active.
+    pub fn add(&mut self, workspace: Entity<Workspace>, window: &Window, cx: &mut Context<Self>) {
         if !self.multi_workspace_enabled(cx) {
-            self.workspaces[0] = workspace;
-            self.active_workspace_index = 0;
-            cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
-            cx.notify();
+            self.set_single_workspace(workspace, cx);
             return;
         }
 
-        let old_index = self.active_workspace_index;
-        let new_index = self.set_active_workspace(workspace, cx);
-        if old_index != new_index {
-            self.serialize(cx);
-        }
+        self.insert_workspace(workspace, window, cx);
     }
 
-    fn set_active_workspace(
+    /// Ensures the workspace is in the multiworkspace and makes it the active one.
+    pub fn activate(
         &mut self,
         workspace: Entity<Workspace>,
+        window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> usize {
-        let index = self.add_workspace(workspace, cx);
+    ) {
+        if !self.multi_workspace_enabled(cx) {
+            self.set_single_workspace(workspace, cx);
+            return;
+        }
+
+        let index = self.insert_workspace(workspace, &*window, cx);
         let changed = self.active_workspace_index != index;
         self.active_workspace_index = index;
         if changed {
             cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
+            self.serialize(cx);
         }
+        self.focus_active_workspace(window, cx);
+        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;
+        cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
         cx.notify();
-        index
     }
 
-    /// Adds a workspace to this window without changing which workspace is active.
-    /// Returns the index of the workspace (existing or newly inserted).
-    pub fn add_workspace(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) -> usize {
+    /// Inserts a workspace into the list if not already present. Returns the
+    /// index of the workspace (existing or newly inserted). Does not change
+    /// the active workspace index.
+    fn insert_workspace(
+        &mut self,
+        workspace: Entity<Workspace>,
+        window: &Window,
+        cx: &mut Context<Self>,
+    ) -> usize {
         if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
             index
         } else {
-            if self.sidebar_open {
-                let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
-                workspace.update(cx, |workspace, _cx| {
-                    workspace.set_sidebar_focus_handle(sidebar_focus_handle);
-                });
-            }
+            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::subscribe_to_workspace(&workspace, cx);
             self.workspaces.push(workspace.clone());
             cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
             cx.notify();
@@ -474,19 +531,35 @@ impl MultiWorkspace {
         }
     }
 
-    pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
-        debug_assert!(
-            index < self.workspaces.len(),
-            "workspace index out of bounds"
-        );
-        let changed = self.active_workspace_index != index;
-        self.active_workspace_index = index;
-        self.serialize(cx);
-        self.focus_active_workspace(window, cx);
-        if changed {
-            cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
+    /// Clears session state and DB binding for a workspace that is being
+    /// removed or replaced. The DB row is preserved so the workspace still
+    /// appears in the recent-projects list.
+    fn detach_workspace(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
+        workspace.update(cx, |workspace, _cx| {
+            workspace.session_id.take();
+            workspace._schedule_serialize_workspace.take();
+            workspace._serialize_workspace_task.take();
+        });
+
+        if let Some(workspace_id) = workspace.read(cx).database_id() {
+            let db = crate::persistence::WorkspaceDb::global(cx);
+            self.pending_removal_tasks.retain(|task| !task.is_ready());
+            self.pending_removal_tasks
+                .push(cx.background_spawn(async move {
+                    db.set_session_binding(workspace_id, None, None)
+                        .await
+                        .log_err();
+                }));
+        }
+    }
+
+    fn sync_sidebar_to_workspace(&self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
+        if self.sidebar_open {
+            let sidebar_focus_handle = self.sidebar.as_ref().map(|s| s.focus_handle(cx));
+            workspace.update(cx, |workspace, _| {
+                workspace.set_sidebar_focus_handle(sidebar_focus_handle);
+            });
         }
-        cx.notify();
     }
 
     fn cycle_workspace(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
@@ -496,7 +569,8 @@ impl MultiWorkspace {
         }
         let current = self.active_workspace_index as isize;
         let next = ((current + delta).rem_euclid(count)) as usize;
-        self.activate_index(next, window, cx);
+        let workspace = self.workspaces[next].clone();
+        self.activate(workspace, window, cx);
     }
 
     fn next_workspace(&mut self, _: &NextWorkspace, window: &mut Window, cx: &mut Context<Self>) {
@@ -666,7 +740,7 @@ impl MultiWorkspace {
         cx: &mut Context<Self>,
     ) -> Entity<Workspace> {
         let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
-        self.activate(workspace.clone(), cx);
+        self.activate(workspace.clone(), window, cx);
         workspace
     }
 
@@ -688,8 +762,7 @@ impl MultiWorkspace {
             cx,
         );
         let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
-        self.set_active_workspace(new_workspace.clone(), cx);
-        self.focus_active_workspace(window, cx);
+        self.activate(new_workspace.clone(), window, cx);
 
         let weak_workspace = new_workspace.downgrade();
         let db = crate::persistence::WorkspaceDb::global(cx);
@@ -716,14 +789,17 @@ impl MultiWorkspace {
         })
     }
 
-    pub fn remove_workspace(
+    pub fn remove(
         &mut self,
-        index: usize,
+        workspace: &Entity<Workspace>,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) -> Option<Entity<Workspace>> {
-        if self.workspaces.len() <= 1 || index >= self.workspaces.len() {
-            return None;
+    ) -> bool {
+        let Some(index) = self.workspaces.iter().position(|w| w == workspace) else {
+            return false;
+        };
+        if self.workspaces.len() <= 1 {
+            return false;
         }
 
         let removed_workspace = self.workspaces.remove(index);
@@ -734,28 +810,7 @@ impl MultiWorkspace {
             self.active_workspace_index -= 1;
         }
 
-        // Clear session_id and cancel any in-flight serialization on the
-        // removed workspace. Without this, a pending throttle timer from
-        // `serialize_workspace` could fire and write the old session_id
-        // back to the DB, resurrecting the workspace on next launch.
-        removed_workspace.update(cx, |workspace, _cx| {
-            workspace.session_id.take();
-            workspace._schedule_serialize_workspace.take();
-            workspace._serialize_workspace_task.take();
-        });
-
-        if let Some(workspace_id) = removed_workspace.read(cx).database_id() {
-            let db = crate::persistence::WorkspaceDb::global(cx);
-            self.pending_removal_tasks.retain(|task| !task.is_ready());
-            self.pending_removal_tasks
-                .push(cx.background_spawn(async move {
-                    // Clear the session binding instead of deleting the row so
-                    // the workspace still appears in the recent-projects list.
-                    db.set_session_binding(workspace_id, None, None)
-                        .await
-                        .log_err();
-                }));
-        }
+        self.detach_workspace(&removed_workspace, cx);
 
         self.serialize(cx);
         self.focus_active_workspace(window, cx);
@@ -765,23 +820,20 @@ impl MultiWorkspace {
         cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
         cx.notify();
 
-        Some(removed_workspace)
+        true
     }
 
     pub fn move_workspace_to_new_window(
         &mut self,
-        index: usize,
+        workspace: &Entity<Workspace>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if self.workspaces.len() <= 1 || index >= self.workspaces.len() {
+        let workspace = workspace.clone();
+        if !self.remove(&workspace, window, cx) {
             return;
         }
 
-        let Some(workspace) = self.remove_workspace(index, window, cx) else {
-            return;
-        };
-
         let app_state: Arc<crate::AppState> = workspace.read(cx).app_state().clone();
 
         cx.defer(move |cx| {
@@ -805,23 +857,28 @@ impl MultiWorkspace {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let index = self.active_workspace_index;
-        self.move_workspace_to_new_window(index, window, cx);
+        let workspace = self.workspace().clone();
+        self.move_workspace_to_new_window(&workspace, window, cx);
     }
 
     pub fn open_project(
         &mut self,
         paths: Vec<PathBuf>,
+        open_mode: OpenMode,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<Entity<Workspace>>> {
         let workspace = self.workspace().clone();
 
-        if self.multi_workspace_enabled(cx) {
-            workspace.update(cx, |workspace, cx| {
-                workspace.open_workspace_for_paths(true, paths, window, cx)
-            })
+        let needs_close_prompt =
+            open_mode == OpenMode::Replace || !self.multi_workspace_enabled(cx);
+        let open_mode = if self.multi_workspace_enabled(cx) {
+            open_mode
         } else {
+            OpenMode::Replace
+        };
+
+        if needs_close_prompt {
             cx.spawn_in(window, async move |_this, cx| {
                 let should_continue = workspace
                     .update_in(cx, |workspace, window, cx| {
@@ -831,13 +888,17 @@ impl MultiWorkspace {
                 if should_continue {
                     workspace
                         .update_in(cx, |workspace, window, cx| {
-                            workspace.open_workspace_for_paths(true, paths, window, cx)
+                            workspace.open_workspace_for_paths(open_mode, paths, window, cx)
                         })?
                         .await
                 } else {
                     Ok(workspace)
                 }
             })
+        } else {
+            workspace.update(cx, |workspace, cx| {
+                workspace.open_workspace_for_paths(open_mode, paths, window, cx)
+            })
         }
     }
 }
@@ -1091,4 +1152,90 @@ mod tests {
             );
         });
     }
+
+    #[gpui::test]
+    async fn test_replace(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;
+
+        let (multi_workspace, cx) = cx
+            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+
+        let workspace_a_id =
+            multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id());
+
+        // 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
+        });
+
+        multi_workspace.read_with(cx, |mw, _cx| {
+            assert_eq!(mw.workspaces().len(), 1);
+            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"
+            );
+        });
+
+        // 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)
+        });
+
+        multi_workspace.read_with(cx, |mw, _cx| {
+            assert_eq!(mw.workspaces().len(), 2);
+            assert_eq!(mw.active_workspace_index(), 1);
+        });
+
+        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
+        });
+
+        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);
+            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"
+            );
+        });
+
+        // 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);
+        });
+
+        multi_workspace.read_with(cx, |mw, _cx| {
+            assert_eq!(
+                mw.workspaces().len(),
+                2,
+                "no workspace should be added or removed"
+            );
+            assert_eq!(
+                mw.active_workspace_index(),
+                0,
+                "should have switched to workspace_b"
+            );
+        });
+    }
 }

crates/workspace/src/persistence.rs 🔗

@@ -2523,7 +2523,7 @@ mod tests {
         let workspace2 = multi_workspace.update_in(cx, |mw, window, cx| {
             let workspace = cx.new(|cx| crate::Workspace::test_new(project2.clone(), window, cx));
             workspace.update(cx, |ws, _cx| ws.set_random_database_id());
-            mw.activate(workspace.clone(), cx);
+            mw.activate(workspace.clone(), window, cx);
             workspace
         });
 
@@ -2541,7 +2541,8 @@ mod tests {
 
         // --- Remove the second workspace (index 1) ---
         multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.remove_workspace(1, window, cx);
+            let ws = mw.workspaces()[1].clone();
+            mw.remove(&ws, window, cx);
         });
 
         cx.run_until_parked();
@@ -4193,7 +4194,7 @@ mod tests {
             workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
                 ws.set_database_id(workspace2_db_id)
             });
-            mw.activate(workspace.clone(), cx);
+            mw.activate(workspace.clone(), window, cx);
         });
 
         // Save a full workspace row to the DB directly.
@@ -4221,7 +4222,8 @@ mod tests {
 
         // Remove workspace at index 1 (the second workspace).
         multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.remove_workspace(1, window, cx);
+            let ws = mw.workspaces()[1].clone();
+            mw.remove(&ws, window, cx);
         });
 
         cx.run_until_parked();
@@ -4291,7 +4293,7 @@ mod tests {
             workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
                 ws.set_database_id(ws2_id)
             });
-            mw.activate(workspace.clone(), cx);
+            mw.activate(workspace.clone(), window, cx);
         });
 
         let session_id = "test-zombie-session";
@@ -4331,7 +4333,8 @@ mod tests {
 
         // Remove workspace2 (index 1).
         multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.remove_workspace(1, window, cx);
+            let ws = mw.workspaces()[1].clone();
+            mw.remove(&ws, window, cx);
         });
 
         cx.run_until_parked();
@@ -4390,7 +4393,7 @@ mod tests {
             workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
                 ws.set_database_id(workspace2_db_id)
             });
-            mw.activate(workspace.clone(), cx);
+            mw.activate(workspace.clone(), window, cx);
         });
 
         // Save a full workspace row to the DB directly and let it settle.
@@ -4414,7 +4417,8 @@ mod tests {
 
         // Remove workspace2 — this pushes a task to pending_removal_tasks.
         multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.remove_workspace(1, window, cx);
+            let ws = mw.workspaces()[1].clone();
+            mw.remove(&ws, window, cx);
         });
 
         // Simulate the quit handler pattern: collect flush tasks + pending

crates/workspace/src/welcome.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{
-    NewFile, Open, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceId,
+    NewFile, Open, OpenMode, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceId,
     item::{Item, ItemEvent},
     persistence::WorkspaceDb,
 };
@@ -326,7 +326,7 @@ impl WelcomePage {
                     self.workspace
                         .update(cx, |workspace, cx| {
                             workspace
-                                .open_workspace_for_paths(true, paths, window, cx)
+                                .open_workspace_for_paths(OpenMode::Replace, paths, window, cx)
                                 .detach_and_log_err(cx);
                         })
                         .log_err();

crates/workspace/src/workspace.rs 🔗

@@ -664,7 +664,15 @@ fn prompt_and_open_paths(app_state: Arc<AppState>, options: PathPromptOptions, c
             })
             .ok();
     } else {
-        let task = Workspace::new_local(Vec::new(), app_state.clone(), None, None, None, true, cx);
+        let task = Workspace::new_local(
+            Vec::new(),
+            app_state.clone(),
+            None,
+            None,
+            None,
+            OpenMode::Replace,
+            cx,
+        );
         cx.spawn(async move |cx| {
             let OpenResult { window, .. } = task.await?;
             window.update(cx, |multi_workspace, window, cx| {
@@ -703,7 +711,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, window, cx)
+                        multi_workspace.open_project(paths, OpenMode::Replace, window, cx)
                     })
                     .log_err()
                 {
@@ -714,7 +722,7 @@ pub fn prompt_for_open_path_and_open(
         }
         if let Some(task) = this
             .update_in(cx, |this, window, cx| {
-                this.open_workspace_for_paths(false, paths, window, cx)
+                this.open_workspace_for_paths(OpenMode::NewWindow, paths, window, cx)
             })
             .log_err()
         {
@@ -1359,6 +1367,19 @@ struct FollowerView {
     location: Option<proto::PanelId>,
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum OpenMode {
+    /// Open the workspace in a new window.
+    NewWindow,
+    /// Add to the window's multi workspace without activating it (used during deserialization).
+    Add,
+    /// 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 {
     pub fn new(
         workspace_id: Option<WorkspaceId>,
@@ -1764,7 +1785,7 @@ impl Workspace {
         requesting_window: Option<WindowHandle<MultiWorkspace>>,
         env: Option<HashMap<String, String>>,
         init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
-        activate: bool,
+        open_mode: OpenMode,
         cx: &mut App,
     ) -> Task<anyhow::Result<OpenResult>> {
         let project_handle = Project::local(
@@ -1862,8 +1883,13 @@ impl Workspace {
                 });
             }
 
+            let window_to_replace = match open_mode {
+                OpenMode::NewWindow => None,
+                _ => requesting_window,
+            };
+
             let (window, workspace): (WindowHandle<MultiWorkspace>, Entity<Workspace>) =
-                if let Some(window) = requesting_window {
+                if let Some(window) = window_to_replace {
                     let centered_layout = serialized_workspace
                         .as_ref()
                         .map(|w| w.centered_layout)
@@ -1888,10 +1914,19 @@ impl Workspace {
 
                             workspace
                         });
-                        if activate {
-                            multi_workspace.activate(workspace.clone(), cx);
-                        } else {
-                            multi_workspace.add_workspace(workspace.clone(), cx);
+                        match open_mode {
+                            OpenMode::Replace => {
+                                multi_workspace.replace(workspace.clone(), &*window, cx);
+                            }
+                            OpenMode::Activate => {
+                                multi_workspace.activate(workspace.clone(), window, cx);
+                            }
+                            OpenMode::Add => {
+                                multi_workspace.add(workspace.clone(), &*window, cx);
+                            }
+                            OpenMode::NewWindow => {
+                                unreachable!()
+                            }
                         }
                         workspace
                     })?;
@@ -2921,7 +2956,7 @@ impl Workspace {
                 None,
                 env,
                 None,
-                true,
+                OpenMode::Activate,
                 cx,
             );
             cx.spawn_in(window, async move |_vh, cx| {
@@ -2962,7 +2997,7 @@ impl Workspace {
                 None,
                 env,
                 None,
-                true,
+                OpenMode::Activate,
                 cx,
             );
             cx.spawn_in(window, async move |_vh, cx| {
@@ -3344,23 +3379,22 @@ impl Workspace {
 
     pub fn open_workspace_for_paths(
         &mut self,
-        replace_current_window: bool,
+        // replace_current_window: bool,
+        mut open_mode: OpenMode,
         paths: Vec<PathBuf>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<Entity<Workspace>>> {
-        let window_handle = window.window_handle().downcast::<MultiWorkspace>();
+        let requesting_window = window.window_handle().downcast::<MultiWorkspace>();
         let is_remote = self.project.read(cx).is_via_collab();
         let has_worktree = self.project.read(cx).worktrees(cx).next().is_some();
         let has_dirty_items = self.items(cx).any(|item| item.is_dirty(cx));
 
-        let window_to_replace = if replace_current_window {
-            window_handle
-        } else if is_remote || has_worktree || has_dirty_items {
-            None
-        } else {
-            window_handle
-        };
+        let workspace_is_empty = !is_remote && !has_worktree && !has_dirty_items;
+        if workspace_is_empty {
+            open_mode = OpenMode::Replace;
+        }
+
         let app_state = self.app_state.clone();
 
         cx.spawn(async move |_, cx| {
@@ -3370,7 +3404,8 @@ impl Workspace {
                         &paths,
                         app_state,
                         OpenOptions {
-                            replace_window: window_to_replace,
+                            requesting_window,
+                            open_mode,
                             ..Default::default()
                         },
                         cx,
@@ -8578,7 +8613,7 @@ pub async fn restore_multiworkspace(
                     None,
                     None,
                     None,
-                    true,
+                    OpenMode::Activate,
                     cx,
                 )
             })
@@ -8608,7 +8643,7 @@ pub async fn restore_multiworkspace(
                     Some(window_handle),
                     None,
                     None,
-                    false,
+                    OpenMode::Add,
                     cx,
                 )
             })
@@ -8628,18 +8663,17 @@ pub async fn restore_multiworkspace(
                     .workspaces()
                     .iter()
                     .position(|ws| ws.read(cx).database_id() == Some(target_id));
-                if let Some(index) = target_index {
-                    multi_workspace.activate_index(index, window, cx);
-                } else if !multi_workspace.workspaces().is_empty() {
-                    multi_workspace.activate_index(0, window, cx);
+                let index = target_index.unwrap_or(0);
+                if let Some(workspace) = multi_workspace.workspaces().get(index).cloned() {
+                    multi_workspace.activate(workspace, window, cx);
                 }
             })
             .ok();
     } else {
         window_handle
             .update(cx, |multi_workspace, window, cx| {
-                if !multi_workspace.workspaces().is_empty() {
-                    multi_workspace.activate_index(0, window, cx);
+                if let Some(workspace) = multi_workspace.workspaces().first().cloned() {
+                    multi_workspace.activate(workspace, window, cx);
                 }
             })
             .ok();
@@ -8890,7 +8924,7 @@ pub fn join_channel(
                         requesting_window,
                         None,
                         None,
-                        true,
+                        OpenMode::Activate,
                         cx,
                     )
                 })
@@ -8963,8 +8997,18 @@ pub async fn get_any_active_multi_workspace(
     // find an existing workspace to focus and show call controls
     let active_window = activate_any_workspace_window(&mut cx);
     if active_window.is_none() {
-        cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), None, None, None, true, cx))
-            .await?;
+        cx.update(|cx| {
+            Workspace::new_local(
+                vec![],
+                app_state.clone(),
+                None,
+                None,
+                None,
+                OpenMode::Activate,
+                cx,
+            )
+        })
+        .await?;
     }
     activate_any_workspace_window(&mut cx).context("could not open zed")
 }
@@ -9134,7 +9178,8 @@ pub struct OpenOptions {
     pub focus: Option<bool>,
     pub open_new_workspace: Option<bool>,
     pub wait: bool,
-    pub replace_window: Option<WindowHandle<MultiWorkspace>>,
+    pub requesting_window: Option<WindowHandle<MultiWorkspace>>,
+    pub open_mode: OpenMode,
     pub env: Option<HashMap<String, String>>,
 }
 
@@ -9189,7 +9234,7 @@ pub fn open_workspace_by_id(
                     workspace.centered_layout = centered_layout;
                     workspace
                 });
-                multi_workspace.add_workspace(workspace.clone(), cx);
+                multi_workspace.add(workspace.clone(), &*window, cx);
                 workspace
             })?;
             (window, workspace)
@@ -9319,7 +9364,7 @@ pub fn open_paths(
             let open_task = existing
                 .update(cx, |multi_workspace, window, cx| {
                     window.activate_window();
-                    multi_workspace.activate(target_workspace.clone(), cx);
+                    multi_workspace.activate(target_workspace.clone(), window, cx);
                     target_workspace.update(cx, |workspace, cx| {
                         workspace.open_paths(
                             abs_paths,
@@ -9353,10 +9398,10 @@ pub fn open_paths(
                     Workspace::new_local(
                         abs_paths,
                         app_state.clone(),
-                        open_options.replace_window,
+                        open_options.requesting_window,
                         open_options.env,
                         None,
-                        true,
+                        open_options.open_mode,
                         cx,
                     )
                 })
@@ -9414,13 +9459,14 @@ pub fn open_new(
     cx: &mut App,
     init: impl FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + 'static + Send,
 ) -> Task<anyhow::Result<()>> {
+    let addition = open_options.open_mode;
     let task = Workspace::new_local(
         Vec::new(),
         app_state,
-        open_options.replace_window,
+        open_options.requesting_window,
         open_options.env,
         Some(Box::new(init)),
-        true,
+        addition,
         cx,
     );
     cx.spawn(async move |cx| {
@@ -9631,7 +9677,7 @@ async fn open_remote_project_inner(
             workspace
         });
 
-        multi_workspace.activate(new_workspace.clone(), cx);
+        multi_workspace.activate(new_workspace.clone(), window, cx);
         new_workspace
     })?;
 
@@ -9718,8 +9764,8 @@ pub fn join_in_room_project(
             existing_window_and_workspace
         {
             existing_window
-                .update(cx, |multi_workspace, _, cx| {
-                    multi_workspace.activate(target_workspace, cx);
+                .update(cx, |multi_workspace, window, cx| {
+                    multi_workspace.activate(target_workspace, window, cx);
                 })
                 .ok();
             existing_window
@@ -10653,7 +10699,8 @@ mod tests {
         // Activate workspace A
         multi_workspace_handle
             .update(cx, |mw, window, cx| {
-                mw.activate_index(0, window, cx);
+                let workspace = mw.workspaces()[0].clone();
+                mw.activate(workspace, window, cx);
             })
             .unwrap();
 
@@ -14422,7 +14469,8 @@ mod tests {
         // Switch to workspace A
         multi_workspace_handle
             .update(cx, |mw, window, cx| {
-                mw.activate_index(0, window, cx);
+                let workspace = mw.workspaces()[0].clone();
+                mw.activate(workspace, window, cx);
             })
             .unwrap();
 
@@ -14467,7 +14515,8 @@ mod tests {
         // Switch to workspace B
         multi_workspace_handle
             .update(cx, |mw, window, cx| {
-                mw.activate_index(1, window, cx);
+                let workspace = mw.workspaces()[1].clone();
+                mw.activate(workspace, window, cx);
             })
             .unwrap();
         cx.run_until_parked();
@@ -14475,7 +14524,8 @@ mod tests {
         // Switch back to workspace A
         multi_workspace_handle
             .update(cx, |mw, window, cx| {
-                mw.activate_index(0, window, cx);
+                let workspace = mw.workspaces()[0].clone();
+                mw.activate(workspace, window, cx);
             })
             .unwrap();
         cx.run_until_parked();

crates/zed/src/visual_test_runner.rs 🔗

@@ -2594,7 +2594,7 @@ fn run_multi_workspace_sidebar_visual_tests(
                     });
                     cx.new(|cx| {
                         let mut multi_workspace = MultiWorkspace::new(workspace1, window, cx);
-                        multi_workspace.activate(workspace2, cx);
+                        multi_workspace.activate(workspace2, window, cx);
                         multi_workspace
                     })
                 },
@@ -2645,7 +2645,8 @@ fn run_multi_workspace_sidebar_visual_tests(
     // Switch to workspace 1 so it's highlighted as active (index 0)
     multi_workspace_window
         .update(cx, |multi_workspace, window, cx| {
-            multi_workspace.activate_index(0, window, cx);
+            let workspace = multi_workspace.workspaces()[0].clone();
+            multi_workspace.activate(workspace, window, cx);
         })
         .context("Failed to activate workspace 1")?;
 

crates/zed/src/zed.rs 🔗

@@ -1121,7 +1121,7 @@ fn register_actions(
                         let task = cx.update(|_window, cx| {
                             open_new(
                                 workspace::OpenOptions {
-                                    replace_window: Some(window_handle),
+                                    requesting_window: Some(window_handle),
                                     ..Default::default()
                                 },
                                 app_state,
@@ -1375,7 +1375,7 @@ fn quit(_: &Quit, cx: &mut App) {
             for workspace in workspaces {
                 if let Some(should_close) = window
                     .update(cx, |multi_workspace, window, cx| {
-                        multi_workspace.activate(workspace.clone(), cx);
+                        multi_workspace.activate(workspace.clone(), window, cx);
                         window.activate_window();
                         workspace.update(cx, |workspace, cx| {
                             workspace.prepare_to_close(CloseIntent::Quit, window, cx)
@@ -2456,7 +2456,7 @@ mod tests {
                 &[PathBuf::from(path!("/root/e"))],
                 app_state,
                 workspace::OpenOptions {
-                    replace_window: Some(window),
+                    requesting_window: Some(window),
                     ..Default::default()
                 },
                 cx,
@@ -5372,11 +5372,11 @@ mod tests {
             .unwrap();
 
         window
-            .update(cx, |multi_workspace, _, cx| {
-                multi_workspace.activate(workspace2.clone(), cx);
-                multi_workspace.activate(workspace3.clone(), cx);
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.activate(workspace2.clone(), window, cx);
+                multi_workspace.activate(workspace3.clone(), window, cx);
                 // Switch back to workspace1 for test setup
-                multi_workspace.activate(workspace1, cx);
+                multi_workspace.activate(workspace1, window, cx);
                 assert_eq!(multi_workspace.active_workspace_index(), 0);
             })
             .unwrap();
@@ -5558,9 +5558,9 @@ mod tests {
             .unwrap();
 
         window1
-            .update(cx, |multi_workspace, _, cx| {
-                multi_workspace.activate(workspace1_2.clone(), cx);
-                multi_workspace.activate(workspace1_1.clone(), cx);
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.activate(workspace1_2.clone(), window, cx);
+                multi_workspace.activate(workspace1_1.clone(), window, cx);
             })
             .unwrap();
 
@@ -5791,7 +5791,7 @@ mod tests {
     async fn test_multi_workspace_session_restore(cx: &mut TestAppContext) {
         use collections::HashMap;
         use session::Session;
-        use workspace::{Workspace, WorkspaceId};
+        use workspace::{OpenMode, Workspace, WorkspaceId};
 
         let app_state = init_test(cx);
 
@@ -5826,7 +5826,7 @@ mod tests {
                     None,
                     None,
                     None,
-                    true,
+                    OpenMode::Activate,
                     cx,
                 )
             })
@@ -5835,7 +5835,7 @@ mod tests {
 
         window_a
             .update(cx, |multi_workspace, window, cx| {
-                multi_workspace.open_project(vec![dir2.into()], window, cx)
+                multi_workspace.open_project(vec![dir2.into()], OpenMode::Activate, window, cx)
             })
             .unwrap()
             .await
@@ -5852,7 +5852,7 @@ mod tests {
                     None,
                     None,
                     None,
-                    true,
+                    OpenMode::Activate,
                     cx,
                 )
             })
@@ -5865,7 +5865,8 @@ mod tests {
         // still be active rather than whichever workspace happened to restore last.
         window_a
             .update(cx, |multi_workspace, window, cx| {
-                multi_workspace.activate_index(0, window, cx);
+                let workspace = multi_workspace.workspaces()[0].clone();
+                multi_workspace.activate(workspace, window, cx);
             })
             .unwrap();
 

crates/zed/src/zed/open_listener.rs 🔗

@@ -529,7 +529,7 @@ async fn open_workspaces(
         };
         let open_options = workspace::OpenOptions {
             open_new_workspace,
-            replace_window,
+            requesting_window: replace_window,
             wait,
             env: env.clone(),
             ..Default::default()
@@ -1292,7 +1292,7 @@ mod tests {
                         vec![],
                         false,
                         workspace::OpenOptions {
-                            replace_window: Some(window_to_replace),
+                            requesting_window: Some(window_to_replace),
                             ..Default::default()
                         },
                         &response_tx,