workspace: Choose the replacement workspace inside MultiWorkspace::remove (#62141)

# Objective

Follow-up to #62028. When archiving removed a thread's workspace, the
replacement
was derived from whichever sidebar row sat adjacent to the archived one.
Rows are
a flat list with project headers as separators, so archiving the oldest
thread in
a project selected the next project's newest thread and moved the user
out of the
project they were working in — and when that neighbor was remote,
connected to
its host just to pick a fallback.

# Solution

- `MultiWorkspace::remove` now chooses the replacement itself from a
`RemovalIntent` (keep the project vs close it): a live workspace in the
same
project, the project's own roots when kept, the nearest retained
neighbor,
an adjacent local project, then an empty workspace. The workspaces being
  removed are excluded from that search in one place instead of five.
- Sidebar neighbor selection stays within the entry's project section.
- The three archive paths now share one removal orchestration, and the
three
  "open the closed workspace first" helpers collapse into one.

# Testing

New tests cover: the keep-project fallback, neighbor selection staying
inside the
project section, and archiving with a mock-remote neighboring project
never
connecting to it. Each was verified to fail against the previous
behavior.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Fixed archiving a thread switching the window to a different project.
- Fixed a possible crash when archiving a thread while its window was
closing.
This commit is contained in:
Ben Kunkle authored and GitHub committed 2026-08-05 02:48:46 +00:00
1 parent e88195fd74
commit 81db8c68b2
9 files changed
+785 -1030

No files matched your search

+17 -13
View File
@@ -20,7 +20,8 @@ use ui::{
use util::ResultExt as _;
use util::paths::PathExt;
use workspace::{
ModalView, MultiWorkspace, Workspace, dock::DockPosition, notifications::DetachAndPromptErr,
ModalView, MultiWorkspace, RemovalIntent, Workspace, dock::DockPosition,
notifications::DetachAndPromptErr,
};
use crate::notifications::show_error_toast;
@@ -448,19 +449,17 @@ impl WorktreePickerDelegate {
&& let Some(workspace) = self.workspace.upgrade()
{
let group_key = workspace.read(cx).project_group_key(cx);
if let Some(group_workspaces) = multi_workspace
for group_workspace in multi_workspace
.read(cx)
.workspaces_for_project_group(&group_key, cx)
{
for group_workspace in group_workspaces {
for worktree in group_workspace
.read(cx)
.project()
.read(cx)
.visible_worktrees(cx)
{
paths.insert(worktree.read(cx).abs_path().to_path_buf());
}
for worktree in group_workspace
.read(cx)
.project()
.read(cx)
.visible_worktrees(cx)
{
paths.insert(worktree.read(cx).abs_path().to_path_buf());
}
}
}
@@ -660,7 +659,7 @@ impl WorktreePickerDelegate {
let group_key = workspace.read(cx).project_group_key(cx);
multi_workspace
.read(cx)
.workspaces_for_project_group(&group_key, cx)?
.workspaces_for_project_group(&group_key, cx)
.into_iter()
.find(|group_workspace| {
*group_workspace != workspace
@@ -693,7 +692,12 @@ impl WorktreePickerDelegate {
cx.spawn_in(window, async move |picker, cx| {
let removed = window_handle
.update(cx, |multi_workspace, window, cx| {
multi_workspace.close_workspace(&workspace_to_remove, window, cx)
multi_workspace.remove(
[workspace_to_remove.clone()],
RemovalIntent::CloseProject,
window,
cx,
)
})?
.await?;
+2 -3
View File
@@ -1194,7 +1194,7 @@ async fn open_worktree_workspace(
None
};
let task = multi_workspace.find_or_create_workspace_with_source_workspace(
let task = multi_workspace.find_or_create_workspace(
path_list,
remote_connection_options,
None,
@@ -1206,7 +1206,6 @@ async fn open_worktree_workspace(
cx,
)
},
&[],
init,
OpenMode::Add,
source_for_transfer.clone(),
@@ -1355,7 +1354,7 @@ async fn open_worktree_workspace(
} else {
// Background open: register the new workspace as a retained tab
// but leave the user where they are.
multi_workspace.add_background_workspace(new_workspace.clone(), window, cx);
multi_workspace.add(new_workspace.clone(), window, cx);
}
if is_creating_new_worktree {
@@ -1183,9 +1183,9 @@ impl PickerDelegate for RecentProjectsDelegate {
cx,
)
},
&[],
None,
OpenMode::Activate,
None,
window,
cx,
)
+212 -504
View File
@@ -64,8 +64,8 @@ use util::ResultExt as _;
use util::path_list::PathList;
use workspace::{
CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextProject,
NextThread, Open, OpenMode, PreviousProject, PreviousThread, ProjectGroupKey, SaveIntent,
Sidebar as WorkspaceSidebar, SidebarSide, Toast, ToggleWorkspaceSidebar, Workspace,
NextThread, Open, OpenMode, PreviousProject, PreviousThread, ProjectGroupKey, RemovalIntent,
SaveIntent, Sidebar as WorkspaceSidebar, SidebarSide, Toast, ToggleWorkspaceSidebar, Workspace,
notifications::NotificationId, sidebar_side_context_menu,
};
@@ -408,7 +408,6 @@ enum ListEntry {
enum ActivatableEntry {
Thread {
metadata: ThreadMetadata,
workspace: ThreadEntryWorkspace,
},
Terminal {
metadata: TerminalThreadMetadata,
@@ -421,7 +420,6 @@ impl ActivatableEntry {
match entry {
ListEntry::Thread(thread) => Some(Self::Thread {
metadata: thread.metadata.clone(),
workspace: thread.workspace.clone(),
}),
ListEntry::Terminal(terminal) => Some(Self::Terminal {
metadata: terminal.metadata.clone(),
@@ -430,38 +428,6 @@ impl ActivatableEntry {
ListEntry::ProjectHeader { .. } => None,
}
}
fn project_location(&self, cx: &App) -> (PathList, ProjectGroupKey) {
match self {
Self::Thread {
workspace: ThreadEntryWorkspace::Open(workspace),
..
}
| Self::Terminal {
workspace: ThreadEntryWorkspace::Open(workspace),
..
} => (
PathList::new(&workspace.read(cx).root_paths(cx)),
workspace.read(cx).project_group_key(cx),
),
Self::Thread {
workspace:
ThreadEntryWorkspace::Closed {
folder_paths,
project_group_key,
},
..
}
| Self::Terminal {
workspace:
ThreadEntryWorkspace::Closed {
folder_paths,
project_group_key,
},
..
} => (folder_paths.clone(), project_group_key.clone()),
}
}
}
#[cfg(test)]
@@ -487,9 +453,9 @@ impl ListEntry {
ThreadEntryWorkspace::Open(workspace) => vec![workspace.clone()],
ThreadEntryWorkspace::Closed { .. } => Vec::new(),
},
ListEntry::ProjectHeader { key, .. } => multi_workspace
.workspaces_for_project_group(key, cx)
.unwrap_or_default(),
ListEntry::ProjectHeader { key, .. } => {
multi_workspace.workspaces_for_project_group(key, cx)
}
}
}
}
@@ -1300,9 +1266,9 @@ impl Sidebar {
host,
provisional_key,
|options, window, cx| connect_remote(active_workspace, options, window, cx),
&[],
None,
OpenMode::Activate,
None,
window,
cx,
)
@@ -1339,9 +1305,9 @@ impl Sidebar {
host,
provisional_key,
|options, window, cx| connect_remote(active_workspace, options, window, cx),
&[],
None,
OpenMode::Activate,
None,
window,
cx,
)
@@ -2539,7 +2505,7 @@ impl Sidebar {
let open_workspaces = self
.multi_workspace
.upgrade()
.and_then(|mw| mw.read(cx).workspaces_for_project_group(key, cx))
.map(|mw| mw.read(cx).workspaces_for_project_group(key, cx))
.unwrap_or_default();
if open_workspaces.is_empty() {
@@ -2761,7 +2727,8 @@ impl Sidebar {
let Some(base) = multi_workspace
.read(cx)
.workspaces_for_project_group(&key, cx)
.and_then(|workspaces| workspaces.first().cloned())
.first()
.cloned()
else {
continue;
};
@@ -2856,9 +2823,7 @@ impl Sidebar {
let open_workspaces = multi_workspace
.read_with(cx, |multi_workspace, cx| {
multi_workspace
.workspaces_for_project_group(&project_group_key, cx)
.unwrap_or_default()
multi_workspace.workspaces_for_project_group(&project_group_key, cx)
})
.unwrap_or_default();
@@ -3057,8 +3022,9 @@ impl Sidebar {
close_multi_workspace
.update(cx, |multi_workspace, cx| {
multi_workspace
.close_workspace(
&close_workspace,
.remove(
[close_workspace.clone()],
RemovalIntent::CloseProject,
window,
cx,
)
@@ -4011,9 +3977,9 @@ impl Sidebar {
host,
provisional_key,
|options, window, cx| connect_remote(active_workspace, options, window, cx),
&[],
None,
OpenMode::Activate,
None,
window,
cx,
)
@@ -4415,21 +4381,43 @@ impl Sidebar {
}
}
/// Find the neighbor thread in the sidebar (by display position).
/// Look below first, then above, for the nearest thread that isn't
/// the one being archived. We capture both the neighbor's metadata
/// (for activation) and its workspace paths (for the workspace
/// removal fallback).
/// Find the entry to select after the entry at `current_position` is
/// removed: the nearest activatable entry in the same project section,
/// below first, then above. Only when that section has no other
/// activatable entry, the nearest one in the whole list.
fn neighboring_activatable_entry(&self, current_position: usize) -> Option<ActivatableEntry> {
let after = self
.contents
.entries
.get(current_position.checked_add(1)?..)?;
let before = self.contents.entries.get(..current_position)?;
after
let entries = &self.contents.entries;
let is_header = |entry: &ListEntry| matches!(entry, ListEntry::ProjectHeader { .. });
let section_start = entries
.get(..current_position)?
.iter()
.chain(before.iter().rev())
.find_map(ActivatableEntry::from_list_entry)
.rposition(is_header)
.map_or(0, |header| header + 1);
let section_end = entries
.get(current_position + 1..)?
.iter()
.position(is_header)
.map_or(entries.len(), |offset| current_position + 1 + offset);
for (start, end) in [(section_start, section_end), (0, entries.len())] {
let Some(before) = entries.get(start..current_position) else {
continue;
};
let Some(after) = entries.get(current_position + 1..end) else {
continue;
};
let Some(entry) = after
.iter()
.chain(before.iter().rev())
.find_map(ActivatableEntry::from_list_entry)
else {
continue;
};
return Some(entry);
}
None
}
fn activate_entry(
@@ -4622,9 +4610,9 @@ impl Sidebar {
host,
provisional_key,
|options, window, cx| connect_remote(active_workspace, options, window, cx),
&[],
None,
OpenMode::Activate,
None,
window,
cx,
)
@@ -4913,15 +4901,18 @@ impl Sidebar {
}
}
/// Closed linked-worktree entries need an open workspace so archive root
/// planning can inspect repositories before deleting the worktree.
fn open_workspace_for_archive(
&mut self,
folder_paths: PathList,
project_group_key: ProjectGroupKey,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<(Task<anyhow::Result<Entity<Workspace>>>, Entity<Workspace>)> {
then: impl FnOnce(&mut Self, Entity<Workspace>, &mut Window, &mut Context<Self>) + 'static,
) {
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
return None;
return;
};
let host = project_group_key.host();
@@ -4934,70 +4925,21 @@ impl Sidebar {
host,
Some(project_group_key),
|options, window, cx| connect_remote(active_workspace, options, window, cx),
&[],
None,
OpenMode::Add,
None,
window,
cx,
)
});
Some((open_task, modal_workspace))
}
fn open_workspace_and_archive_thread(
&mut self,
session_id: acp::SessionId,
folder_paths: PathList,
project_group_key: ProjectGroupKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((open_task, modal_workspace)) =
self.open_workspace_for_archive(folder_paths, project_group_key, window, cx)
else {
return;
};
cx.spawn_in(window, async move |this, cx| {
let result = open_task.await;
remote_connection::dismiss_connection_modal(&modal_workspace, cx);
let workspace = result?;
Self::wait_for_archive_workspace_metadata(&workspace, cx).await;
this.update_in(cx, |this, window, cx| {
this.update_entries(cx);
this.archive_thread(&session_id, window, cx);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn open_workspace_and_close_terminal(
&mut self,
metadata: TerminalThreadMetadata,
folder_paths: PathList,
project_group_key: ProjectGroupKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((open_task, modal_workspace)) =
self.open_workspace_for_archive(folder_paths, project_group_key, window, cx)
else {
return;
};
cx.spawn_in(window, async move |this, cx| {
let result = open_task.await;
remote_connection::dismiss_connection_modal(&modal_workspace, cx);
let workspace = result?;
Self::wait_for_archive_workspace_metadata(&workspace, cx).await;
this.update_in(cx, |this, window, cx| {
let workspace = ThreadEntryWorkspace::Open(workspace);
this.close_terminal(&metadata, &workspace, window, cx);
})?;
this.update_in(cx, |this, window, cx| then(this, workspace, window, cx))?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
@@ -5023,12 +4965,20 @@ impl Sidebar {
cx,
)
{
self.open_workspace_and_close_terminal(
metadata.clone(),
let metadata = metadata.clone();
self.open_workspace_for_archive(
folder_paths.clone(),
project_group_key.clone(),
window,
cx,
move |this, workspace, window, cx| {
this.close_terminal(
&metadata,
&ThreadEntryWorkspace::Open(workspace),
window,
cx,
);
},
);
return;
}
@@ -5078,123 +5028,40 @@ impl Sidebar {
cx,
);
if !workspaces_to_remove.is_empty() {
let multi_workspace = self.multi_workspace.upgrade().unwrap();
let terminal_workspace_removed = matches!(
workspace,
ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace)
);
let (fallback_paths, project_group_key) = neighbor
.as_ref()
.map(|neighbor| neighbor.project_location(cx))
.unwrap_or_else(|| {
workspaces_to_remove
.first()
.map(|workspace| {
let key = workspace.read(cx).project_group_key(cx);
(key.path_list().clone(), key)
})
.unwrap_or_default()
});
let terminal_workspace_removed = matches!(
workspace,
ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace)
);
let metadata = metadata.clone();
let workspace = workspace.clone();
let excluded = workspaces_to_remove.clone();
let remove_task = multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.remove(
workspaces_to_remove,
move |this, window, cx| {
let active_workspace = this.workspace().clone();
this.find_or_create_workspace(
fallback_paths,
project_group_key.host(),
Some(project_group_key),
|options, window, cx| {
connect_remote(active_workspace, options, window, cx)
},
&excluded,
None,
OpenMode::Activate,
window,
cx,
)
},
self.remove_workspaces_then(
workspaces_to_remove,
close_item_tasks,
window,
cx,
move |this, window, cx| {
if terminal_workspace_removed {
this.delete_empty_drafts_for_archive_paths(
metadata.folder_paths(),
metadata.remote_connection.as_ref(),
cx,
);
}
// If the terminal's workspace has already been removed, don't
// synthesize a fallback draft in the detached AgentPanel.
this.close_terminal_entry(
&metadata,
&workspace,
is_active,
neighbor.as_ref(),
!terminal_workspace_removed,
roots_to_archive,
window,
cx,
)
});
let metadata = metadata.clone();
let workspace = workspace.clone();
cx.spawn_in(window, async move |this, cx| {
if !remove_task.await? {
return anyhow::Ok(());
}
for task in close_item_tasks {
let result: anyhow::Result<()> = task.await;
result.log_err();
}
this.update_in(cx, |this, window, cx| {
if terminal_workspace_removed {
this.delete_empty_drafts_for_archive_paths(
metadata.folder_paths(),
metadata.remote_connection.as_ref(),
cx,
);
}
// If the terminal's workspace has already been removed,
// don't synthesize a fallback draft in the detached
// AgentPanel.
this.close_terminal_entry(
&metadata,
&workspace,
is_active,
neighbor.as_ref(),
!terminal_workspace_removed,
roots_to_archive,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else if !close_item_tasks.is_empty() {
let metadata = metadata.clone();
let workspace = workspace.clone();
cx.spawn_in(window, async move |this, cx| {
for task in close_item_tasks {
let result: anyhow::Result<()> = task.await;
result.log_err();
}
this.update_in(cx, |this, window, cx| {
this.close_terminal_entry(
&metadata,
&workspace,
is_active,
neighbor.as_ref(),
true,
roots_to_archive,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else {
self.close_terminal_entry(
metadata,
workspace,
is_active,
neighbor.as_ref(),
true,
roots_to_archive,
window,
cx,
);
}
);
},
);
}
fn close_terminal_entry(
@@ -5256,6 +5123,48 @@ impl Sidebar {
self.update_entries(cx);
}
fn remove_workspaces_then(
&mut self,
workspaces_to_remove: Vec<Entity<Workspace>>,
close_item_tasks: Vec<Task<anyhow::Result<()>>>,
window: &mut Window,
cx: &mut Context<Self>,
finish: impl FnOnce(&mut Self, &mut Window, &mut Context<Self>) + 'static,
) {
if workspaces_to_remove.is_empty() && close_item_tasks.is_empty() {
finish(self, window, cx);
return;
}
let remove_task = if workspaces_to_remove.is_empty() {
None
} else {
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
return;
};
Some(multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.remove(workspaces_to_remove, RemovalIntent::KeepProject, window, cx)
}))
};
cx.spawn_in(window, async move |this, cx| {
if let Some(remove_task) = remove_task
&& !remove_task.await?
{
return anyhow::Ok(());
}
for task in close_item_tasks {
let result: anyhow::Result<()> = task.await;
result.log_err();
}
this.update_in(cx, |this, window, cx| finish(this, window, cx))?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn close_items_for_archived_worktrees(
&self,
roots_to_archive: &[thread_worktree_archive::RootPlan],
@@ -5397,12 +5306,16 @@ impl Sidebar {
cx,
)
{
self.open_workspace_and_archive_thread(
session_id.clone(),
let session_id = session_id.clone();
self.open_workspace_for_archive(
folder_paths,
project_group_key,
window,
cx,
move |this, _workspace, window, cx| {
this.update_entries(cx);
this.archive_thread(&session_id, window, cx);
},
);
return;
}
@@ -5466,133 +5379,40 @@ impl Sidebar {
cx,
);
if !workspaces_to_remove.is_empty() {
let multi_workspace = self.multi_workspace.upgrade().unwrap();
let session_id = session_id.clone();
let removed_workspace = !workspaces_to_remove.is_empty();
let session_id = session_id.clone();
let thread_remote_connection = metadata
.as_ref()
.and_then(|metadata| metadata.remote_connection.clone());
let (fallback_paths, project_group_key) = neighbor
.as_ref()
.map(|neighbor| neighbor.project_location(cx))
.unwrap_or_else(|| {
workspaces_to_remove
.first()
.map(|workspace| {
let key = workspace.read(cx).project_group_key(cx);
(key.path_list().clone(), key)
})
.unwrap_or_default()
});
let excluded = workspaces_to_remove.clone();
let remove_task = multi_workspace.update(cx, |mw, cx| {
mw.remove(
workspaces_to_remove,
move |this, window, cx| {
let active_workspace = this.workspace().clone();
this.find_or_create_workspace(
fallback_paths,
project_group_key.host(),
Some(project_group_key),
|options, window, cx| {
connect_remote(active_workspace, options, window, cx)
},
&excluded,
None,
OpenMode::Activate,
window,
cx,
)
},
self.remove_workspaces_then(
workspaces_to_remove,
close_item_tasks,
window,
cx,
move |this, window, cx| {
if removed_workspace && let Some(thread_folder_paths) = thread_folder_paths.as_ref()
{
this.delete_empty_drafts_for_archive_paths(
thread_folder_paths,
thread_remote_connection.as_ref(),
cx,
);
}
let in_flight = thread_id
.and_then(|tid| this.start_archive_worktree_task(tid, roots_to_archive, cx));
this.archive_and_activate(
&session_id,
thread_id,
neighbor.as_ref(),
thread_folder_paths.as_ref(),
thread_remote_connection.as_ref(),
in_flight,
window,
cx,
)
});
let thread_folder_paths = thread_folder_paths.clone();
let thread_remote_connection = metadata
.as_ref()
.and_then(|metadata| metadata.remote_connection.clone());
cx.spawn_in(window, async move |this, cx| {
if !remove_task.await? {
return anyhow::Ok(());
}
for task in close_item_tasks {
let result: anyhow::Result<()> = task.await;
result.log_err();
}
this.update_in(cx, |this, window, cx| {
if let Some(thread_folder_paths) = thread_folder_paths.as_ref() {
this.delete_empty_drafts_for_archive_paths(
thread_folder_paths,
thread_remote_connection.as_ref(),
cx,
);
}
let in_flight = thread_id.and_then(|tid| {
this.start_archive_worktree_task(tid, roots_to_archive, cx)
});
this.archive_and_activate(
&session_id,
thread_id,
neighbor.as_ref(),
thread_folder_paths.as_ref(),
thread_remote_connection.as_ref(),
in_flight,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else if !close_item_tasks.is_empty() {
let session_id = session_id.clone();
let thread_folder_paths = thread_folder_paths.clone();
let thread_remote_connection = metadata
.as_ref()
.and_then(|metadata| metadata.remote_connection.clone());
cx.spawn_in(window, async move |this, cx| {
for task in close_item_tasks {
let result: anyhow::Result<()> = task.await;
result.log_err();
}
this.update_in(cx, |this, window, cx| {
let in_flight = thread_id.and_then(|tid| {
this.start_archive_worktree_task(tid, roots_to_archive, cx)
});
this.archive_and_activate(
&session_id,
thread_id,
neighbor.as_ref(),
thread_folder_paths.as_ref(),
thread_remote_connection.as_ref(),
in_flight,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else {
let in_flight = thread_id
.and_then(|tid| self.start_archive_worktree_task(tid, roots_to_archive, cx));
self.archive_and_activate(
session_id,
thread_id,
neighbor.as_ref(),
thread_folder_paths.as_ref(),
metadata
.as_ref()
.and_then(|metadata| metadata.remote_connection.as_ref()),
in_flight,
window,
cx,
);
}
);
},
);
}
/// Archive a thread and activate the nearest neighbor or a draft.
@@ -6831,37 +6651,6 @@ impl Sidebar {
}
}
/// Closed linked-worktree drafts need an open workspace so archive root
/// planning can inspect repositories before deleting the worktree.
fn open_workspace_and_remove_draft(
&mut self,
draft_id: ThreadId,
folder_paths: PathList,
project_group_key: ProjectGroupKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((open_task, modal_workspace)) =
self.open_workspace_for_archive(folder_paths, project_group_key, window, cx)
else {
return;
};
cx.spawn_in(window, async move |this, cx| {
let result = open_task.await;
remote_connection::dismiss_connection_modal(&modal_workspace, cx);
let workspace = result?;
Self::wait_for_archive_workspace_metadata(&workspace, cx).await;
this.update_in(cx, |this, window, cx| {
let workspace = ThreadEntryWorkspace::Open(workspace);
this.remove_draft(draft_id, &workspace, window, cx);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn remove_draft(
&mut self,
draft_id: ThreadId,
@@ -6889,12 +6678,14 @@ impl Sidebar {
cx,
)
{
self.open_workspace_and_remove_draft(
draft_id,
self.open_workspace_for_archive(
folder_paths.clone(),
project_group_key.clone(),
window,
cx,
move |this, workspace, window, cx| {
this.remove_draft(draft_id, &ThreadEntryWorkspace::Open(workspace), window, cx);
},
);
return;
}
@@ -6959,122 +6750,39 @@ impl Sidebar {
cx,
);
if !workspaces_to_remove.is_empty() {
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
return;
};
let draft_workspace_removed = matches!(
workspace,
ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace)
);
let (fallback_paths, project_group_key) = neighbor
.as_ref()
.map(|neighbor| neighbor.project_location(cx))
.unwrap_or_else(|| {
workspaces_to_remove
.first()
.map(|workspace| {
let key = workspace.read(cx).project_group_key(cx);
(key.path_list().clone(), key)
})
.unwrap_or_default()
});
let draft_workspace_removed = matches!(
workspace,
ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace)
);
let workspace = workspace.clone();
let excluded = workspaces_to_remove.clone();
let remove_task = multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.remove(
workspaces_to_remove,
move |this, window, cx| {
let active_workspace = this.workspace().clone();
this.find_or_create_workspace(
fallback_paths,
project_group_key.host(),
Some(project_group_key),
|options, window, cx| {
connect_remote(active_workspace, options, window, cx)
},
&excluded,
None,
OpenMode::Activate,
window,
cx,
)
},
self.remove_workspaces_then(
workspaces_to_remove,
close_item_tasks,
window,
cx,
move |this, window, cx| {
if draft_workspace_removed
&& let Some(draft_folder_paths) = draft_folder_paths.as_ref()
{
this.delete_empty_drafts_for_archive_paths(
draft_folder_paths,
draft_remote_connection.as_ref(),
cx,
);
}
this.remove_draft_entry(
draft_id,
&workspace,
was_active,
neighbor.as_ref(),
!draft_workspace_removed,
roots_to_archive,
window,
cx,
)
});
let workspace = workspace.clone();
cx.spawn_in(window, async move |this, cx| {
if !remove_task.await? {
return anyhow::Ok(());
}
for task in close_item_tasks {
let result: anyhow::Result<()> = task.await;
result.log_err();
}
this.update_in(cx, |this, window, cx| {
if draft_workspace_removed {
if let Some(draft_folder_paths) = draft_folder_paths.as_ref() {
this.delete_empty_drafts_for_archive_paths(
draft_folder_paths,
draft_remote_connection.as_ref(),
cx,
);
}
}
this.remove_draft_entry(
draft_id,
&workspace,
was_active,
neighbor.as_ref(),
!draft_workspace_removed,
roots_to_archive,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else if !close_item_tasks.is_empty() {
let workspace = workspace.clone();
cx.spawn_in(window, async move |this, cx| {
for task in close_item_tasks {
let result: anyhow::Result<()> = task.await;
result.log_err();
}
this.update_in(cx, |this, window, cx| {
this.remove_draft_entry(
draft_id,
&workspace,
was_active,
neighbor.as_ref(),
true,
roots_to_archive,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else {
self.remove_draft_entry(
draft_id,
workspace,
was_active,
neighbor.as_ref(),
true,
roots_to_archive,
window,
cx,
);
}
);
},
);
}
fn remove_draft_entry(
+125 -11
View File
@@ -1055,6 +1055,80 @@ async fn test_collapse_state_survives_worktree_key_change(cx: &mut TestAppContex
);
}
#[gpui::test]
async fn test_neighboring_activatable_entry_stays_within_project(cx: &mut TestAppContext) {
let project = init_test_project("/my-project", cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let header = |path: &str| ListEntry::ProjectHeader {
key: ProjectGroupKey::new(None, PathList::new(&[std::path::PathBuf::from(path)])),
label: path.into(),
highlight_positions: Vec::new(),
has_running_threads: false,
waiting_thread_count: 0,
has_notifications: false,
is_active: false,
has_threads: true,
};
let thread = |name: &str| {
ListEntry::Thread(Arc::new(ThreadEntry {
metadata: ThreadMetadata {
thread_id: ThreadId::new(),
session_id: Some(acp::SessionId::new(Arc::from(name))),
agent_id: AgentId::new("zed-agent"),
worktree_paths: WorktreePaths::default(),
title: Some(name.to_string().into()),
title_override: None,
updated_at: Utc::now(),
created_at: Some(Utc::now()),
interacted_at: None,
archived: false,
remote_connection: None,
},
icon: IconName::ZedAgent,
icon_from_external_svg: None,
status: AgentThreadStatus::Completed,
workspace: ThreadEntryWorkspace::Open(workspace.clone()),
is_live: false,
is_background: false,
is_title_generating: false,
draft: None,
highlight_positions: Vec::new(),
worktrees: Vec::new(),
diff_stats: DiffStats::default(),
}))
};
sidebar.update_in(cx, |s, _window, _cx| {
s.contents.entries = vec![
header("/project-a"),
thread("a-newest"),
thread("a-oldest"),
header("/project-b"),
thread("b-newest"),
];
let neighbor_session = |position: usize| match s.neighboring_activatable_entry(position) {
Some(ActivatableEntry::Thread { metadata, .. }) => metadata.session_id,
_ => None,
};
assert_eq!(
neighbor_session(2),
Some(acp::SessionId::new(Arc::from("a-newest"))),
"the neighbor should be the sibling in the same project, not the next project's thread"
);
assert_eq!(
neighbor_session(4),
Some(acp::SessionId::new(Arc::from("a-oldest"))),
"an empty project should fall back to another project"
);
});
}
#[gpui::test]
async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
use workspace::ProjectGroup;
@@ -8236,6 +8310,7 @@ async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppCon
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(main_project.clone(), window, cx));
let sidebar = setup_sidebar(&multi_workspace, cx);
let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
mw.test_add_workspace(worktree_project.clone(), window, cx)
@@ -8265,6 +8340,38 @@ async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppCon
);
cx.run_until_parked();
let remote_host =
remote::RemoteConnectionOptions::Mock(remote::MockConnectionOptions { id: 99 });
multi_workspace.update(cx, |mw, _cx| {
mw.test_add_project_group(workspace::ProjectGroup {
key: ProjectGroupKey::new(
Some(remote_host.clone()),
PathList::new(&[PathBuf::from("/remote/project")]),
),
workspaces: Vec::new(),
expanded: true,
});
});
cx.update(|_window, cx| {
let metadata = ThreadMetadata {
thread_id: ThreadId::new(),
session_id: Some(acp::SessionId::new(Arc::from("remote-thread"))),
agent_id: agent::ZED_AGENT_ID.clone(),
title: Some("Remote Thread".into()),
title_override: None,
updated_at: chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 3, 0, 0, 0).unwrap(),
created_at: None,
interacted_at: None,
worktree_paths: WorktreePaths::from_folder_paths(&PathList::new(&[PathBuf::from(
"/remote/project",
)])),
archived: false,
remote_connection: Some(remote_host),
};
ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save(metadata, cx));
});
cx.run_until_parked();
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
cx.run_until_parked();
@@ -8294,6 +8401,14 @@ async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppCon
"linked worktree workspace should be removed after archiving its last thread"
);
multi_workspace.read_with(cx, |mw, _| {
assert_eq!(
mw.workspace(),
&main_workspace,
"archiving the worktree's last thread should activate its own project, not the remote one"
);
});
// The linked worktree checkout directory should also be removed from disk.
assert!(
!fs.is_dir(Path::new("/worktrees/project/feature-a/project"))
@@ -8746,12 +8861,10 @@ async fn test_restore_worktree_thread_uses_main_repo_project_group_key(cx: &mut
cx.run_until_parked();
// Remove the worktree workspace and delete the worktree from disk.
let main_workspace =
multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
let remove_task = multi_workspace.update_in(cx, |mw, window, cx| {
mw.remove(
vec![worktree_workspace],
move |_this, _window, _cx| Task::ready(Ok(main_workspace)),
RemovalIntent::KeepProject,
window,
cx,
)
@@ -12459,7 +12572,8 @@ mod property_test {
let key = &keys[project_group_index];
let ws = mw
.workspaces_for_project_group(key, cx)
.and_then(|ws| ws.first().cloned())
.first()
.cloned()
.unwrap_or_else(|| mw.workspace().clone());
let project = ws.read(cx).project().clone();
(ws, project)
@@ -12612,7 +12726,8 @@ mod property_test {
let keys = mw.project_group_keys();
let key = &keys[index];
mw.workspaces_for_project_group(key, cx)
.and_then(|ws| ws.first().cloned())
.first()
.cloned()
.unwrap_or_else(|| mw.workspace().clone())
});
multi_workspace.update_in(cx, |mw, window, cx| {
@@ -12681,7 +12796,8 @@ mod property_test {
let keys = mw.project_group_keys();
let key = &keys[project_group_index];
mw.workspaces_for_project_group(key, cx)
.and_then(|ws| ws.first().cloned())
.first()
.cloned()
.unwrap()
});
let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
@@ -12700,8 +12816,7 @@ mod property_test {
let workspace = multi_workspace.read_with(cx, |mw, cx| {
let keys = mw.project_group_keys();
let key = &keys[project_group_index];
mw.workspaces_for_project_group(key, cx)
.and_then(|ws| ws.first().cloned())
mw.workspaces_for_project_group(key, cx).first().cloned()
});
let Some(workspace) = workspace else { return };
let project = workspace.read_with(cx, |ws, _| ws.project().clone());
@@ -12728,8 +12843,7 @@ mod property_test {
let workspace = multi_workspace.read_with(cx, |mw, cx| {
let keys = mw.project_group_keys();
let key = &keys[project_group_index];
mw.workspaces_for_project_group(key, cx)
.and_then(|ws| ws.first().cloned())
mw.workspaces_for_project_group(key, cx).first().cloned()
});
let Some(workspace) = workspace else { return };
let project = workspace.read_with(cx, |ws, _| ws.project().clone());
@@ -14978,9 +15092,9 @@ async fn test_find_or_create_workspace_returns_the_created_remote_workspace(
Some(opts),
Some(key),
move |_, _, _| Task::ready(Ok(Some(remote_client))),
&[],
None,
workspace::OpenMode::Activate,
None,
window,
cx,
)
File diff suppressed because it is too large. Load diff
+89 -42
View File
@@ -432,9 +432,9 @@ async fn test_find_or_create_local_workspace_reuses_active_workspace_when_sideba
mw.find_or_create_local_workspace(
PathList::new(&[PathBuf::from("/root_a")]),
None,
&[],
None,
OpenMode::Activate,
None,
window,
cx,
)
@@ -497,9 +497,9 @@ async fn test_find_or_create_workspace_uses_project_group_key_when_paths_are_mis
None,
Some(project_group_key.clone()),
|_options, _window, _cx| Task::ready(Ok(None)),
&[],
None,
OpenMode::Activate,
None,
window,
cx,
)
@@ -555,22 +555,9 @@ async fn test_remove_fallback_via_find_or_create_skips_removed_workspaces(cx: &m
let removed = multi_workspace
.update_in(cx, |mw, window, cx| {
let excluded = vec![workspace_a.clone()];
mw.remove(
excluded.clone(),
move |this, window, cx| {
this.find_or_create_workspace(
PathList::new(&[PathBuf::from("/root_a")]),
None,
None,
|_options, _window, _cx| Task::ready(Ok(None)),
&excluded,
None,
OpenMode::Activate,
window,
cx,
)
},
vec![workspace_a.clone()],
RemovalIntent::CloseProject,
window,
cx,
)
@@ -593,6 +580,52 @@ async fn test_remove_fallback_via_find_or_create_skips_removed_workspaces(cx: &m
});
}
#[gpui::test]
async fn test_remove_keeping_the_project_does_not_switch_projects(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
let project_b = Project::test(fs, ["/root_b".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
let _workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
mw.test_add_workspace(project_b, window, cx)
});
cx.run_until_parked();
multi_workspace.update_in(cx, |mw, window, cx| {
mw.activate(workspace_a.clone(), None, window, cx);
});
cx.run_until_parked();
multi_workspace
.update_in(cx, |mw, window, cx| {
mw.remove(
vec![workspace_a.clone()],
RemovalIntent::KeepProject,
window,
cx,
)
})
.await
.expect("removing the active workspace should succeed");
cx.run_until_parked();
multi_workspace.read_with(cx, |mw, cx| {
assert_eq!(
PathList::new(&mw.workspace().read(cx).root_paths(cx)),
PathList::new(&[PathBuf::from("/root_a")]),
"the replacement workspace should be in the removed workspace's project"
);
});
}
#[gpui::test]
async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sidebar_open(
cx: &mut TestAppContext,
@@ -625,9 +658,9 @@ async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sideb
mw.find_or_create_local_workspace(
PathList::new(&[PathBuf::from("/root_a")]),
None,
&[],
None,
OpenMode::Activate,
None,
window,
cx,
)
@@ -711,7 +744,12 @@ async fn test_close_workspace_prefers_already_loaded_neighboring_workspace(
let closed = multi_workspace
.update_in(cx, |multi_workspace, window, cx| {
multi_workspace.close_workspace(&workspace_a, window, cx)
multi_workspace.remove(
[workspace_a.clone()],
RemovalIntent::CloseProject,
window,
cx,
)
})
.await
.expect("closing the active workspace should succeed");
@@ -735,7 +773,6 @@ async fn test_close_workspace_prefers_already_loaded_neighboring_workspace(
assert!(
multi_workspace
.workspaces_for_project_group(&project_c_key, cx)
.unwrap_or_default()
.is_empty(),
"the unloaded neighboring group C should remain unopened"
);
@@ -758,7 +795,6 @@ async fn test_close_workspace_prefers_workspace_in_same_project_group(cx: &mut T
let (workspace_a_1, workspace_a_2) = multi_workspace.read_with(cx, |multi_workspace, cx| {
let mut workspaces = multi_workspace
.workspaces_for_project_group(&key_a, cx)
.expect("project group A should have retained workspaces")
.into_iter();
let first = workspaces
.next()
@@ -781,7 +817,12 @@ async fn test_close_workspace_prefers_workspace_in_same_project_group(cx: &mut T
let closed = multi_workspace
.update_in(cx, |multi_workspace, window, cx| {
multi_workspace.close_workspace(&workspace_a_1, window, cx)
multi_workspace.remove(
[workspace_a_1.clone()],
RemovalIntent::CloseProject,
window,
cx,
)
})
.await
.expect("closing the active workspace should succeed");
@@ -795,9 +836,7 @@ async fn test_close_workspace_prefers_workspace_in_same_project_group(cx: &mut T
);
assert_eq!(
multi_workspace
.workspaces_for_project_group(&key_a, cx)
.expect("project group A should remain"),
multi_workspace.workspaces_for_project_group(&key_a, cx),
vec![workspace_a_2],
"only the fallback workspace should remain in project group A"
);
@@ -830,7 +869,12 @@ async fn test_close_workspace_opens_unloaded_local_neighbor(cx: &mut TestAppCont
let closed = multi_workspace
.update_in(cx, |multi_workspace, window, cx| {
multi_workspace.close_workspace(&workspace_a, window, cx)
multi_workspace.remove(
[workspace_a.clone()],
RemovalIntent::CloseProject,
window,
cx,
)
})
.await
.expect("closing the active workspace should succeed");
@@ -1294,7 +1338,12 @@ async fn test_close_workspace_with_remote_neighbor_does_not_create_local_workspa
// to creating an empty workspace instead.
multi_workspace
.update_in(cx, |mw, window, cx| {
mw.close_workspace(&workspace_a, window, cx)
mw.remove(
[workspace_a.clone()],
RemovalIntent::CloseProject,
window,
cx,
)
})
.await
.expect("close_workspace should succeed");
@@ -1418,15 +1467,16 @@ async fn test_nearest_retained_workspace(cx: &mut TestAppContext) {
.expect("project group for project-c should exist");
let workspace_b = multi_workspace
.workspaces_for_project_group(&key_b, cx)
.and_then(|workspaces| workspaces.into_iter().next())
.into_iter()
.next()
.expect("workspace for project-b should exist");
let workspace_d = multi_workspace
.workspaces_for_project_group(&key_d, cx)
.and_then(|workspaces| workspaces.into_iter().next())
.into_iter()
.next()
.expect("workspace for project-d should exist");
let retained_workspaces_a = multi_workspace
.workspaces_for_project_group(&key_a, cx)
.expect("project group A should have retained workspaces");
.workspaces_for_project_group(&key_a, cx);
assert_eq!(
retained_workspaces_a.len(),
2,
@@ -1477,23 +1527,20 @@ async fn test_nearest_retained_workspace(cx: &mut TestAppContext) {
"the farther group's last active workspace should be preferred"
);
// We'll now clear the project group's last active workspace.
// In that state, the search falls back to the group's first retained
// workspace.
multi_workspace
.group_state_by_key_mut(&key_a)
.expect("project group A should exist")
.last_active_workspace
.take();
// With the group's most recently activated workspace excluded, the
// search falls back to the member activated before it.
assert_eq!(
multi_workspace.nearest_retained_workspace(
group_c_index,
&[workspace_b.clone(), workspace_d.clone()],
&[
workspace_b.clone(),
workspace_d.clone(),
workspace_a_2.clone()
],
cx
),
Some(workspace_a_1.clone()),
"the first retained workspace should be used without a last active workspace"
"the previously activated workspace should be used when the last active one is excluded"
);
// Excluding every neighboring workspace exhausts the search.
+8 -23
View File
@@ -2764,9 +2764,9 @@ pub fn delete_unloaded_items(
#[cfg(test)]
mod tests {
use super::*;
use crate::OpenMode;
use crate::PathList;
use crate::ProjectGroupKey;
use crate::RemovalIntent;
use crate::{
multi_workspace::MultiWorkspace,
persistence::{
@@ -2843,7 +2843,7 @@ mod tests {
.workspaces()
.find(|ws| *ws != &active)
.expect("should have a non-active workspace");
mw.remove([ws.clone()], |_, _, _| unreachable!(), _window, cx)
mw.remove([ws.clone()], RemovalIntent::CloseProject, _window, cx)
.detach_and_log_err(cx);
});
@@ -4811,7 +4811,7 @@ mod tests {
// Remove workspace at index 1 (the second workspace).
multi_workspace.update_in(cx, |mw, window, cx| {
let ws = mw.workspaces().nth(1).unwrap().clone();
mw.remove([ws], |_, _, _| unreachable!(), window, cx)
mw.remove([ws], RemovalIntent::CloseProject, window, cx)
.detach_and_log_err(cx);
});
@@ -4921,7 +4921,7 @@ mod tests {
// Remove workspace2 (index 1).
multi_workspace.update_in(cx, |mw, window, cx| {
let ws = mw.workspaces().nth(1).unwrap().clone();
mw.remove([ws], |_, _, _| unreachable!(), window, cx)
mw.remove([ws], RemovalIntent::CloseProject, window, cx)
.detach_and_log_err(cx);
});
@@ -5002,7 +5002,7 @@ mod tests {
// Remove workspace2 — this pushes a task to pending_removal_tasks.
multi_workspace.update_in(cx, |mw, window, cx| {
let ws = mw.workspaces().nth(1).unwrap().clone();
mw.remove([ws], |_, _, _| unreachable!(), window, cx)
mw.remove([ws], RemovalIntent::CloseProject, window, cx)
.detach_and_log_err(cx);
});
@@ -5955,27 +5955,12 @@ mod tests {
});
cx.run_until_parked();
// Remove workspace_a. The fallback searches for the same paths.
// Without the `excluding` parameter, `workspace_for_paths` would
// return workspace_a (first match) and the assert in `remove`
// would fire. With the fix, workspace_a is skipped and
// workspace_b is found instead.
let path_list = PathList::new(std::slice::from_ref(&dir));
let excluded = vec![workspace_a.clone()];
// Remove workspace_a. Its replacement is looked up by the same paths,
// so workspace_a itself has to be skipped or it would be picked again.
multi_workspace.update_in(cx, |mw, window, cx| {
mw.remove(
vec![workspace_a.clone()],
move |this, window, cx| {
this.find_or_create_local_workspace(
path_list,
None,
&excluded,
None,
OpenMode::Activate,
window,
cx,
)
},
RemovalIntent::CloseProject,
window,
cx,
)
+14 -4
View File
@@ -33,8 +33,8 @@ pub use dock::Panel;
pub use multi_workspace::{
CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MoveProjectToNewWindow,
MultiWorkspace, MultiWorkspaceEvent, NewThread, NextProject, NextThread, PreviousProject,
PreviousThread, ProjectGroup, ProjectGroupKey, SerializedProjectGroupState, Sidebar,
SidebarEvent, SidebarHandle, SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar,
PreviousThread, ProjectGroup, ProjectGroupKey, RemovalIntent, SerializedProjectGroupState,
Sidebar, SidebarEvent, SidebarHandle, SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar,
sidebar_side_context_menu,
};
pub use path_list::{PathList, SerializedPathList};
@@ -12056,7 +12056,12 @@ mod tests {
// Try to remove workspace B. It should prompt because of the dirty item.
let remove_task = multi_workspace_handle
.update(cx, |mw, window, cx| {
mw.remove([workspace_b.clone()], |_, _, _| unreachable!(), window, cx)
mw.remove(
[workspace_b.clone()],
RemovalIntent::CloseProject,
window,
cx,
)
})
.unwrap();
cx.run_until_parked();
@@ -12094,7 +12099,12 @@ mod tests {
.update(cx, |mw, window, cx| {
// First switch back to A.
mw.activate(workspace_a.clone(), None, window, cx);
mw.remove([workspace_b.clone()], |_, _, _| unreachable!(), window, cx)
mw.remove(
[workspace_b.clone()],
RemovalIntent::CloseProject,
window,
cx,
)
})
.unwrap();
cx.run_until_parked();