From 7fc65c7db68eccfc98f1d2ba1affd641d75a7078 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 21 Apr 2026 13:09:34 -0700 Subject: [PATCH] Close the empty project before adding a project to a window (#54450) 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 --- crates/workspace/src/multi_workspace.rs | 58 ++++++-- crates/workspace/src/multi_workspace_tests.rs | 138 +++++++++++++++++- 2 files changed, 186 insertions(+), 10 deletions(-) diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index a70104818de544e0bf48274b30be64e4a29dc577..c1802e797c936789f6e7997f135fee4f5a088f47 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -1930,15 +1930,55 @@ impl MultiWorkspace { cx: &mut Context, ) -> Task>> { 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| { diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index 37ebf691492a75f1db9a21a7f50d00a443291914..3b715fe80ca2b86598a5e0baba2acc9c31f1200c 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/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()),); +}