Close the empty project before adding a project to a window (#54450)

Max Brunsfeld created

This prevents the user from getting into a state where they have unsaved
untitled buffers, but no way to get back to them.

Release Notes:

- N/A

Change summary

crates/workspace/src/multi_workspace.rs       |  58 +++++++-
crates/workspace/src/multi_workspace_tests.rs | 138 ++++++++++++++++++++
2 files changed, 186 insertions(+), 10 deletions(-)

Detailed changes

crates/workspace/src/multi_workspace.rs 🔗

@@ -1930,15 +1930,55 @@ impl MultiWorkspace {
         cx: &mut Context<Self>,
     ) -> Task<Result<Entity<Workspace>>> {
         if self.multi_workspace_enabled(cx) {
-            self.find_or_create_local_workspace(
-                PathList::new(&paths),
-                None,
-                &[],
-                None,
-                OpenMode::Activate,
-                window,
-                cx,
-            )
+            let empty_workspace = if self
+                .active_workspace
+                .read(cx)
+                .project()
+                .read(cx)
+                .visible_worktrees(cx)
+                .next()
+                .is_none()
+            {
+                Some(self.active_workspace.clone())
+            } else {
+                None
+            };
+
+            cx.spawn_in(window, async move |this, cx| {
+                if let Some(empty_workspace) = empty_workspace.as_ref() {
+                    let should_continue = empty_workspace
+                        .update_in(cx, |workspace, window, cx| {
+                            workspace.prepare_to_close(CloseIntent::ReplaceWindow, window, cx)
+                        })?
+                        .await?;
+                    if !should_continue {
+                        return Ok(empty_workspace.clone());
+                    }
+                }
+
+                let create_task = this.update_in(cx, |this, window, cx| {
+                    this.find_or_create_local_workspace(
+                        PathList::new(&paths),
+                        None,
+                        empty_workspace.as_slice(),
+                        None,
+                        OpenMode::Activate,
+                        window,
+                        cx,
+                    )
+                })?;
+                let new_workspace = create_task.await?;
+
+                if let Some(empty_workspace) = empty_workspace {
+                    this.update(cx, |this, cx| {
+                        if this.is_workspace_retained(&empty_workspace) {
+                            this.detach_workspace(&empty_workspace, cx);
+                        }
+                    })?;
+                }
+
+                Ok(new_workspace)
+            })
         } else {
             let workspace = self.workspace().clone();
             cx.spawn_in(window, async move |_this, cx| {

crates/workspace/src/multi_workspace_tests.rs 🔗

@@ -1,9 +1,10 @@
 use std::path::PathBuf;
 
 use super::*;
+use crate::item::test::TestItem;
 use client::proto;
 use fs::{FakeFs, Fs};
-use gpui::TestAppContext;
+use gpui::{TestAppContext, VisualTestContext};
 use project::DisableAiSettings;
 use serde_json::json;
 use settings::SettingsStore;
@@ -767,3 +768,138 @@ async fn test_remote_project_root_dir_changes_update_groups(cx: &mut TestAppCont
         );
     });
 }
+
+#[gpui::test]
+async fn test_open_project_closes_empty_workspace_but_not_non_empty_ones(cx: &mut TestAppContext) {
+    init_test(cx);
+    let app_state = cx.update(AppState::test);
+    let fs = app_state.fs.as_fake();
+    fs.insert_tree(path!("/project_a"), json!({ "file_a.txt": "" }))
+        .await;
+    fs.insert_tree(path!("/project_b"), json!({ "file_b.txt": "" }))
+        .await;
+
+    // Start with an empty (no-worktrees) workspace.
+    let project = Project::test(app_state.fs.clone(), [], cx).await;
+    let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
+    cx.run_until_parked();
+
+    window
+        .update(cx, |mw, _window, cx| mw.open_sidebar(cx))
+        .unwrap();
+    cx.run_until_parked();
+
+    let empty_workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+
+    // Add a dirty untitled item to the empty workspace.
+    let dirty_item = cx.new(|cx| TestItem::new(cx).with_dirty(true));
+    empty_workspace.update_in(cx, |workspace, window, cx| {
+        workspace.add_item_to_active_pane(Box::new(dirty_item.clone()), None, true, window, cx);
+    });
+
+    // Opening a project while the lone empty workspace has unsaved
+    // changes prompts the user.
+    let open_task = window
+        .update(cx, |mw, window, cx| {
+            mw.open_project(
+                vec![PathBuf::from(path!("/project_a"))],
+                OpenMode::Activate,
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    cx.run_until_parked();
+
+    // Cancelling keeps the empty workspace.
+    assert!(cx.has_pending_prompt(),);
+    cx.simulate_prompt_answer("Cancel");
+    cx.run_until_parked();
+    assert_eq!(open_task.await.unwrap(), empty_workspace);
+    window
+        .read_with(cx, |mw, _cx| {
+            assert_eq!(mw.workspaces().count(), 1);
+            assert_eq!(mw.workspace(), &empty_workspace);
+            assert_eq!(mw.project_group_keys(), vec![]);
+        })
+        .unwrap();
+
+    // Discarding the unsaved changes closes the empty workspace
+    // and opens the new project in its place.
+    let open_task = window
+        .update(cx, |mw, window, cx| {
+            mw.open_project(
+                vec![PathBuf::from(path!("/project_a"))],
+                OpenMode::Activate,
+                window,
+                cx,
+            )
+        })
+        .unwrap();
+    cx.run_until_parked();
+
+    assert!(cx.has_pending_prompt(),);
+    cx.simulate_prompt_answer("Don't Save");
+    cx.run_until_parked();
+
+    let workspace_a = open_task.await.unwrap();
+    assert_ne!(workspace_a, empty_workspace);
+
+    window
+        .read_with(cx, |mw, _cx| {
+            assert_eq!(mw.workspaces().count(), 1);
+            assert_eq!(mw.workspace(), &workspace_a);
+            assert_eq!(
+                mw.project_group_keys(),
+                vec![ProjectGroupKey::new(
+                    None,
+                    PathList::new(&[path!("/project_a")])
+                )]
+            );
+        })
+        .unwrap();
+    assert!(
+        empty_workspace.read_with(cx, |workspace, _cx| workspace.session_id().is_none()),
+        "the detached empty workspace should no longer be attached to the session",
+    );
+
+    let dirty_item = cx.new(|cx| TestItem::new(cx).with_dirty(true));
+    workspace_a.update_in(cx, |workspace, window, cx| {
+        workspace.add_item_to_active_pane(Box::new(dirty_item.clone()), None, true, window, cx);
+    });
+
+    // Opening another project does not close the existing project or prompt.
+    let workspace_b = window
+        .update(cx, |mw, window, cx| {
+            mw.open_project(
+                vec![PathBuf::from(path!("/project_b"))],
+                OpenMode::Activate,
+                window,
+                cx,
+            )
+        })
+        .unwrap()
+        .await
+        .unwrap();
+    cx.run_until_parked();
+
+    assert!(!cx.has_pending_prompt());
+    assert_ne!(workspace_b, workspace_a);
+    window
+        .read_with(cx, |mw, _cx| {
+            assert_eq!(mw.workspaces().count(), 2);
+            assert_eq!(mw.workspace(), &workspace_b);
+            assert_eq!(
+                mw.project_group_keys(),
+                vec![
+                    ProjectGroupKey::new(None, PathList::new(&[path!("/project_b")])),
+                    ProjectGroupKey::new(None, PathList::new(&[path!("/project_a")]))
+                ]
+            );
+        })
+        .unwrap();
+    assert!(workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_some()),);
+}