mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
Fix workspace removal, and chase down all the ramifications (#53366)
This PR adjusts how project group removal works in the sidebar to be project group aware. Removal is now a batched operation, and supports a contextually appropriate fallback active workspace fallback. In the process of removing this, I had to chase down and fix other broken code: - Removes the "move to new window" actions - Changed the sidebar to store things in "most recently added" order - The recent project UI was still using workspaces, instead of project groups - Adjusted the archive command to use the new APIs to remove the workspace associated with that worktree (cc: @rtfeldman) - The property tests where still using indexes and workspaces instead of project groups Self-Review Checklist: - [ ] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #ISSUE Release Notes: - N/A
This commit is contained in:
parent
a2cf71d09e
commit
6286b7c5fb
11 changed files with 1499 additions and 954 deletions
|
|
@ -1294,6 +1294,14 @@ impl AgentPanel {
|
|||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Reset the panel to the uninitialized state, clearing any active
|
||||
/// thread without creating a new draft. Running threads are retained
|
||||
/// in the background. The sidebar suppresses the uninitialized state
|
||||
/// so no "Draft" entry appears.
|
||||
pub fn clear_active_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_active_view(ActiveView::Uninitialized, false, window, cx);
|
||||
}
|
||||
|
||||
pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.reset_start_thread_in_to_default(cx);
|
||||
self.external_thread(None, None, None, None, None, true, window, cx);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ pub mod sidebar_recent_projects;
|
|||
mod ssh_config;
|
||||
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
|
@ -33,7 +32,7 @@ use picker::{
|
|||
Picker, PickerDelegate,
|
||||
highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
|
||||
};
|
||||
use project::{Worktree, git_store::Repository};
|
||||
use project::{ProjectGroupKey, Worktree, git_store::Repository};
|
||||
pub use remote_connections::RemoteSettings;
|
||||
pub use remote_servers::RemoteServerProjects;
|
||||
use settings::{Settings, WorktreeId};
|
||||
|
|
@ -79,7 +78,7 @@ struct OpenFolderEntry {
|
|||
enum ProjectPickerEntry {
|
||||
Header(SharedString),
|
||||
OpenFolder { index: usize, positions: Vec<usize> },
|
||||
OpenProject(StringMatch),
|
||||
ProjectGroup(StringMatch),
|
||||
RecentProject(StringMatch),
|
||||
}
|
||||
|
||||
|
|
@ -355,10 +354,8 @@ pub fn init(cx: &mut App) {
|
|||
cx.defer(move |cx| {
|
||||
multi_workspace
|
||||
.update(cx, |multi_workspace, window, cx| {
|
||||
let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
|
||||
.workspaces()
|
||||
.filter_map(|ws| ws.read(cx).database_id())
|
||||
.collect();
|
||||
let window_project_groups: Vec<ProjectGroupKey> =
|
||||
multi_workspace.project_group_keys().cloned().collect();
|
||||
|
||||
let workspace = multi_workspace.workspace().clone();
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
|
|
@ -369,7 +366,7 @@ pub fn init(cx: &mut App) {
|
|||
RecentProjects::open(
|
||||
workspace,
|
||||
create_new_window,
|
||||
sibling_workspace_ids,
|
||||
window_project_groups,
|
||||
window,
|
||||
focus_handle,
|
||||
cx,
|
||||
|
|
@ -394,7 +391,7 @@ pub fn init(cx: &mut App) {
|
|||
RecentProjects::open(
|
||||
workspace,
|
||||
create_new_window,
|
||||
HashSet::new(),
|
||||
Vec::new(),
|
||||
window,
|
||||
focus_handle,
|
||||
cx,
|
||||
|
|
@ -611,7 +608,7 @@ impl RecentProjects {
|
|||
pub fn open(
|
||||
workspace: &mut Workspace,
|
||||
create_new_window: bool,
|
||||
sibling_workspace_ids: HashSet<WorkspaceId>,
|
||||
window_project_groups: Vec<ProjectGroupKey>,
|
||||
window: &mut Window,
|
||||
focus_handle: FocusHandle,
|
||||
cx: &mut Context<Workspace>,
|
||||
|
|
@ -627,7 +624,7 @@ impl RecentProjects {
|
|||
create_new_window,
|
||||
focus_handle,
|
||||
open_folders,
|
||||
sibling_workspace_ids,
|
||||
window_project_groups,
|
||||
project_connection_options,
|
||||
ProjectPickerStyle::Modal,
|
||||
);
|
||||
|
|
@ -638,7 +635,7 @@ impl RecentProjects {
|
|||
|
||||
pub fn popover(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
sibling_workspace_ids: HashSet<WorkspaceId>,
|
||||
window_project_groups: Vec<ProjectGroupKey>,
|
||||
create_new_window: bool,
|
||||
focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
|
|
@ -662,7 +659,7 @@ impl RecentProjects {
|
|||
create_new_window,
|
||||
focus_handle,
|
||||
open_folders,
|
||||
sibling_workspace_ids,
|
||||
window_project_groups,
|
||||
project_connection_options,
|
||||
ProjectPickerStyle::Popover,
|
||||
);
|
||||
|
|
@ -715,17 +712,17 @@ impl RecentProjects {
|
|||
picker.update_matches(query, window, cx);
|
||||
}
|
||||
}
|
||||
Some(ProjectPickerEntry::OpenProject(hit)) => {
|
||||
if let Some((workspace_id, ..)) =
|
||||
picker.delegate.workspaces.get(hit.candidate_id)
|
||||
Some(ProjectPickerEntry::ProjectGroup(hit)) => {
|
||||
if let Some(key) = picker
|
||||
.delegate
|
||||
.window_project_groups
|
||||
.get(hit.candidate_id)
|
||||
.cloned()
|
||||
{
|
||||
let workspace_id = *workspace_id;
|
||||
if picker.delegate.is_current_workspace(workspace_id, cx) {
|
||||
if picker.delegate.is_active_project_group(&key, cx) {
|
||||
return;
|
||||
}
|
||||
picker
|
||||
.delegate
|
||||
.remove_sibling_workspace(workspace_id, window, cx);
|
||||
picker.delegate.remove_project_group(key, window, cx);
|
||||
let query = picker.query(cx);
|
||||
picker.update_matches(query, window, cx);
|
||||
}
|
||||
|
|
@ -788,7 +785,7 @@ impl Render for RecentProjects {
|
|||
pub struct RecentProjectsDelegate {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
open_folders: Vec<OpenFolderEntry>,
|
||||
sibling_workspace_ids: HashSet<WorkspaceId>,
|
||||
window_project_groups: Vec<ProjectGroupKey>,
|
||||
workspaces: Vec<(
|
||||
WorkspaceId,
|
||||
SerializedWorkspaceLocation,
|
||||
|
|
@ -814,7 +811,7 @@ impl RecentProjectsDelegate {
|
|||
create_new_window: bool,
|
||||
focus_handle: FocusHandle,
|
||||
open_folders: Vec<OpenFolderEntry>,
|
||||
sibling_workspace_ids: HashSet<WorkspaceId>,
|
||||
window_project_groups: Vec<ProjectGroupKey>,
|
||||
project_connection_options: Option<RemoteConnectionOptions>,
|
||||
style: ProjectPickerStyle,
|
||||
) -> Self {
|
||||
|
|
@ -822,7 +819,7 @@ impl RecentProjectsDelegate {
|
|||
Self {
|
||||
workspace,
|
||||
open_folders,
|
||||
sibling_workspace_ids,
|
||||
window_project_groups,
|
||||
workspaces: Vec::new(),
|
||||
filtered_entries: Vec::new(),
|
||||
selected_index: 0,
|
||||
|
|
@ -901,7 +898,7 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
self.filtered_entries.get(ix),
|
||||
Some(
|
||||
ProjectPickerEntry::OpenFolder { .. }
|
||||
| ProjectPickerEntry::OpenProject(_)
|
||||
| ProjectPickerEntry::ProjectGroup(_)
|
||||
| ProjectPickerEntry::RecentProject(_)
|
||||
)
|
||||
)
|
||||
|
|
@ -938,13 +935,13 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
))
|
||||
};
|
||||
|
||||
let sibling_candidates: Vec<_> = self
|
||||
.workspaces
|
||||
let project_group_candidates: Vec<_> = self
|
||||
.window_project_groups
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (id, _, _, _))| self.sibling_workspace_ids.contains(id))
|
||||
.map(|(id, (_, _, paths, _))| {
|
||||
let combined_string = paths
|
||||
.map(|(id, key)| {
|
||||
let combined_string = key
|
||||
.path_list()
|
||||
.ordered_paths()
|
||||
.map(|path| path.compact().to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
|
|
@ -953,8 +950,8 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
})
|
||||
.collect();
|
||||
|
||||
let mut sibling_matches = smol::block_on(fuzzy::match_strings(
|
||||
&sibling_candidates,
|
||||
let mut project_group_matches = smol::block_on(fuzzy::match_strings(
|
||||
&project_group_candidates,
|
||||
query,
|
||||
smart_case,
|
||||
true,
|
||||
|
|
@ -962,7 +959,7 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
&Default::default(),
|
||||
cx.background_executor().clone(),
|
||||
));
|
||||
sibling_matches.sort_unstable_by(|a, b| {
|
||||
project_group_matches.sort_unstable_by(|a, b| {
|
||||
b.score
|
||||
.partial_cmp(&a.score)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
|
|
@ -1020,29 +1017,27 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
let has_siblings_to_show = if is_empty_query {
|
||||
!sibling_candidates.is_empty()
|
||||
let has_projects_to_show = if is_empty_query {
|
||||
!project_group_candidates.is_empty()
|
||||
} else {
|
||||
!sibling_matches.is_empty()
|
||||
!project_group_matches.is_empty()
|
||||
};
|
||||
|
||||
if has_siblings_to_show {
|
||||
if has_projects_to_show {
|
||||
entries.push(ProjectPickerEntry::Header("This Window".into()));
|
||||
|
||||
if is_empty_query {
|
||||
for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
|
||||
if self.sibling_workspace_ids.contains(workspace_id) {
|
||||
entries.push(ProjectPickerEntry::OpenProject(StringMatch {
|
||||
candidate_id: id,
|
||||
score: 0.0,
|
||||
positions: Vec::new(),
|
||||
string: String::new(),
|
||||
}));
|
||||
}
|
||||
for id in 0..self.window_project_groups.len() {
|
||||
entries.push(ProjectPickerEntry::ProjectGroup(StringMatch {
|
||||
candidate_id: id,
|
||||
score: 0.0,
|
||||
positions: Vec::new(),
|
||||
string: String::new(),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
for m in sibling_matches {
|
||||
entries.push(ProjectPickerEntry::OpenProject(m));
|
||||
for m in project_group_matches {
|
||||
entries.push(ProjectPickerEntry::ProjectGroup(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1101,32 +1096,23 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
}
|
||||
cx.emit(DismissEvent);
|
||||
}
|
||||
Some(ProjectPickerEntry::OpenProject(selected_match)) => {
|
||||
let Some((workspace_id, _, _, _)) =
|
||||
self.workspaces.get(selected_match.candidate_id)
|
||||
else {
|
||||
Some(ProjectPickerEntry::ProjectGroup(selected_match)) => {
|
||||
let Some(key) = self.window_project_groups.get(selected_match.candidate_id) else {
|
||||
return;
|
||||
};
|
||||
let workspace_id = *workspace_id;
|
||||
|
||||
if self.is_current_workspace(workspace_id, cx) {
|
||||
cx.emit(DismissEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
let path_list = key.path_list().clone();
|
||||
if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
|
||||
cx.defer(move |cx| {
|
||||
handle
|
||||
if let Some(task) = handle
|
||||
.update(cx, |multi_workspace, window, cx| {
|
||||
let workspace = multi_workspace
|
||||
.workspaces()
|
||||
.find(|ws| ws.read(cx).database_id() == Some(workspace_id))
|
||||
.cloned();
|
||||
if let Some(workspace) = workspace {
|
||||
multi_workspace.activate(workspace, window, cx);
|
||||
}
|
||||
multi_workspace
|
||||
.find_or_create_local_workspace(path_list, window, cx)
|
||||
})
|
||||
.log_err();
|
||||
.log_err()
|
||||
{
|
||||
task.detach_and_log_err(cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
cx.emit(DismissEvent);
|
||||
|
|
@ -1354,28 +1340,18 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
ProjectPickerEntry::OpenProject(hit) => {
|
||||
let (workspace_id, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
|
||||
let workspace_id = *workspace_id;
|
||||
let is_current = self.is_current_workspace(workspace_id, cx);
|
||||
ProjectPickerEntry::ProjectGroup(hit) => {
|
||||
let key = self.window_project_groups.get(hit.candidate_id)?;
|
||||
let is_active = self.is_active_project_group(key, cx);
|
||||
let paths = key.path_list();
|
||||
let ordered_paths: Vec<_> = paths
|
||||
.ordered_paths()
|
||||
.map(|p| p.compact().to_string_lossy().to_string())
|
||||
.collect();
|
||||
let tooltip_path: SharedString = match &location {
|
||||
SerializedWorkspaceLocation::Remote(options) => {
|
||||
let host = options.display_name();
|
||||
if ordered_paths.len() == 1 {
|
||||
format!("{} ({})", ordered_paths[0], host).into()
|
||||
} else {
|
||||
format!("{}\n({})", ordered_paths.join("\n"), host).into()
|
||||
}
|
||||
}
|
||||
_ => ordered_paths.join("\n").into(),
|
||||
};
|
||||
let tooltip_path: SharedString = ordered_paths.join("\n").into();
|
||||
|
||||
let mut path_start_offset = 0;
|
||||
let (match_labels, paths): (Vec<_>, Vec<_>) = paths
|
||||
let (match_labels, path_highlights): (Vec<_>, Vec<_>) = paths
|
||||
.ordered_paths()
|
||||
.map(|p| p.compact())
|
||||
.map(|path| {
|
||||
|
|
@ -1386,43 +1362,35 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
})
|
||||
.unzip();
|
||||
|
||||
let prefix = match &location {
|
||||
SerializedWorkspaceLocation::Remote(options) => {
|
||||
Some(SharedString::from(options.display_name()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let highlighted_match = HighlightedMatchWithPaths {
|
||||
prefix,
|
||||
prefix: None,
|
||||
match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
|
||||
paths,
|
||||
active: is_current,
|
||||
paths: path_highlights,
|
||||
active: is_active,
|
||||
};
|
||||
|
||||
let icon = icon_for_remote_connection(match location {
|
||||
SerializedWorkspaceLocation::Local => None,
|
||||
SerializedWorkspaceLocation::Remote(options) => Some(options),
|
||||
});
|
||||
|
||||
let project_group_key = key.clone();
|
||||
let secondary_actions = h_flex()
|
||||
.gap_1()
|
||||
.when(!is_current, |this| {
|
||||
.when(!is_active, |this| {
|
||||
this.child(
|
||||
IconButton::new("remove_open_project", IconName::Close)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(Tooltip::text("Remove Project from Window"))
|
||||
.on_click(cx.listener(move |picker, _, window, cx| {
|
||||
cx.stop_propagation();
|
||||
window.prevent_default();
|
||||
picker.delegate.remove_sibling_workspace(
|
||||
workspace_id,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let query = picker.query(cx);
|
||||
picker.update_matches(query, window, cx);
|
||||
})),
|
||||
.on_click({
|
||||
let project_group_key = project_group_key.clone();
|
||||
cx.listener(move |picker, _, window, cx| {
|
||||
cx.stop_propagation();
|
||||
window.prevent_default();
|
||||
picker.delegate.remove_project_group(
|
||||
project_group_key.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let query = picker.query(cx);
|
||||
picker.update_matches(query, window, cx);
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
.into_any_element();
|
||||
|
|
@ -1436,10 +1404,6 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
h_flex()
|
||||
.id("open_project_info_container")
|
||||
.gap_3()
|
||||
.flex_grow()
|
||||
.when(self.has_any_non_local_projects, |this| {
|
||||
this.child(Icon::new(icon).color(Color::Muted))
|
||||
})
|
||||
.child({
|
||||
let mut highlighted = highlighted_match;
|
||||
if !self.render_paths {
|
||||
|
|
@ -1609,7 +1573,7 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
|
||||
let is_already_open_entry = matches!(
|
||||
self.filtered_entries.get(self.selected_index),
|
||||
Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::OpenProject(_))
|
||||
Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::ProjectGroup(_))
|
||||
);
|
||||
|
||||
if popover_style {
|
||||
|
|
@ -1655,11 +1619,10 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
let selected_entry = self.filtered_entries.get(self.selected_index);
|
||||
|
||||
let is_current_workspace_entry =
|
||||
if let Some(ProjectPickerEntry::OpenProject(hit)) = selected_entry {
|
||||
self.workspaces
|
||||
if let Some(ProjectPickerEntry::ProjectGroup(hit)) = selected_entry {
|
||||
self.window_project_groups
|
||||
.get(hit.candidate_id)
|
||||
.map(|(id, ..)| self.is_current_workspace(*id, cx))
|
||||
.unwrap_or(false)
|
||||
.is_some_and(|key| self.is_active_project_group(key, cx))
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
|
@ -1677,7 +1640,7 @@ impl PickerDelegate for RecentProjectsDelegate {
|
|||
})
|
||||
.into_any_element(),
|
||||
),
|
||||
Some(ProjectPickerEntry::OpenProject(_)) if !is_current_workspace_entry => Some(
|
||||
Some(ProjectPickerEntry::ProjectGroup(_)) if !is_current_workspace_entry => Some(
|
||||
Button::new("remove_selected", "Remove from Window")
|
||||
.key_binding(KeyBinding::for_action_in(
|
||||
&RemoveSelected,
|
||||
|
|
@ -1961,29 +1924,26 @@ impl RecentProjectsDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
fn remove_sibling_workspace(
|
||||
fn remove_project_group(
|
||||
&mut self,
|
||||
workspace_id: WorkspaceId,
|
||||
key: ProjectGroupKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) {
|
||||
if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
|
||||
let key_for_remove = key.clone();
|
||||
cx.defer(move |cx| {
|
||||
handle
|
||||
.update(cx, |multi_workspace, window, cx| {
|
||||
let workspace = multi_workspace
|
||||
.workspaces()
|
||||
.find(|ws| ws.read(cx).database_id() == Some(workspace_id))
|
||||
.cloned();
|
||||
if let Some(workspace) = workspace {
|
||||
multi_workspace.remove(&workspace, window, cx);
|
||||
}
|
||||
multi_workspace
|
||||
.remove_project_group(&key_for_remove, window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
})
|
||||
.log_err();
|
||||
});
|
||||
}
|
||||
|
||||
self.sibling_workspace_ids.remove(&workspace_id);
|
||||
self.window_project_groups.retain(|k| k != &key);
|
||||
}
|
||||
|
||||
fn is_current_workspace(
|
||||
|
|
@ -2001,13 +1961,17 @@ impl RecentProjectsDelegate {
|
|||
false
|
||||
}
|
||||
|
||||
fn is_sibling_workspace(
|
||||
&self,
|
||||
workspace_id: WorkspaceId,
|
||||
cx: &mut Context<Picker<Self>>,
|
||||
) -> bool {
|
||||
self.sibling_workspace_ids.contains(&workspace_id)
|
||||
&& !self.is_current_workspace(workspace_id, cx)
|
||||
fn is_active_project_group(&self, key: &ProjectGroupKey, cx: &App) -> bool {
|
||||
if let Some(workspace) = self.workspace.upgrade() {
|
||||
return workspace.read(cx).project_group_key(cx) == *key;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_in_current_window_groups(&self, paths: &PathList) -> bool {
|
||||
self.window_project_groups
|
||||
.iter()
|
||||
.any(|key| key.path_list() == paths)
|
||||
}
|
||||
|
||||
fn is_open_folder(&self, paths: &PathList) -> bool {
|
||||
|
|
@ -2033,195 +1997,21 @@ impl RecentProjectsDelegate {
|
|||
cx: &mut Context<Picker<Self>>,
|
||||
) -> bool {
|
||||
!self.is_current_workspace(workspace_id, cx)
|
||||
&& !self.is_sibling_workspace(workspace_id, cx)
|
||||
&& !self.is_in_current_window_groups(paths)
|
||||
&& !self.is_open_folder(paths)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use editor::Editor;
|
||||
use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle};
|
||||
use gpui::{TestAppContext, VisualTestContext};
|
||||
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use util::path;
|
||||
use workspace::{AppState, open_paths};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_dirty_workspace_replaced_when_opening_recent_project(cx: &mut TestAppContext) {
|
||||
let app_state = init_test(cx);
|
||||
|
||||
cx.update(|cx| {
|
||||
SettingsStore::update_global(cx, |store, cx| {
|
||||
store.update_user_settings(cx, |settings| {
|
||||
settings
|
||||
.session
|
||||
.get_or_insert_default()
|
||||
.restore_unsaved_buffers = Some(false)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app_state
|
||||
.fs
|
||||
.as_fake()
|
||||
.insert_tree(
|
||||
path!("/dir"),
|
||||
json!({
|
||||
"main.ts": "a"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
app_state
|
||||
.fs
|
||||
.as_fake()
|
||||
.insert_tree(path!("/test/path"), json!({}))
|
||||
.await;
|
||||
cx.update(|cx| {
|
||||
open_paths(
|
||||
&[PathBuf::from(path!("/dir/main.ts"))],
|
||||
app_state,
|
||||
workspace::OpenOptions::default(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
|
||||
|
||||
let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
|
||||
multi_workspace
|
||||
.update(cx, |multi_workspace, _, cx| {
|
||||
multi_workspace.open_sidebar(cx);
|
||||
})
|
||||
.unwrap();
|
||||
multi_workspace
|
||||
.update(cx, |multi_workspace, _, cx| {
|
||||
assert!(!multi_workspace.workspace().read(cx).is_edited())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let editor = multi_workspace
|
||||
.read_with(cx, |multi_workspace, cx| {
|
||||
multi_workspace
|
||||
.workspace()
|
||||
.read(cx)
|
||||
.active_item(cx)
|
||||
.unwrap()
|
||||
.downcast::<Editor>()
|
||||
.unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
multi_workspace
|
||||
.update(cx, |_, window, cx| {
|
||||
editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
|
||||
})
|
||||
.unwrap();
|
||||
multi_workspace
|
||||
.update(cx, |multi_workspace, _, cx| {
|
||||
assert!(
|
||||
multi_workspace.workspace().read(cx).is_edited(),
|
||||
"After inserting more text into the editor without saving, we should have a dirty project"
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
|
||||
multi_workspace
|
||||
.update(cx, |_, _, cx| {
|
||||
recent_projects_picker.update(cx, |picker, cx| {
|
||||
assert_eq!(picker.query(cx), "");
|
||||
let delegate = &mut picker.delegate;
|
||||
delegate.set_workspaces(vec![(
|
||||
WorkspaceId::default(),
|
||||
SerializedWorkspaceLocation::Local,
|
||||
PathList::new(&[path!("/test/path")]),
|
||||
Utc::now(),
|
||||
)]);
|
||||
delegate.filtered_entries =
|
||||
vec![ProjectPickerEntry::RecentProject(StringMatch {
|
||||
candidate_id: 0,
|
||||
score: 1.0,
|
||||
positions: Vec::new(),
|
||||
string: "fake candidate".to_string(),
|
||||
})];
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!cx.has_pending_prompt(),
|
||||
"Should have no pending prompt on dirty project before opening the new recent project"
|
||||
);
|
||||
let dirty_workspace = multi_workspace
|
||||
.read_with(cx, |multi_workspace, _cx| {
|
||||
multi_workspace.workspace().clone()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
cx.dispatch_action(*multi_workspace, menu::Confirm);
|
||||
cx.run_until_parked();
|
||||
|
||||
// In multi-workspace mode, the dirty workspace is kept and a new one is
|
||||
// opened alongside it — no save prompt needed.
|
||||
assert!(
|
||||
!cx.has_pending_prompt(),
|
||||
"Should not prompt in multi-workspace mode — dirty workspace is kept"
|
||||
);
|
||||
|
||||
multi_workspace
|
||||
.update(cx, |multi_workspace, _, cx| {
|
||||
assert!(
|
||||
multi_workspace
|
||||
.workspace()
|
||||
.read(cx)
|
||||
.active_modal::<RecentProjects>(cx)
|
||||
.is_none(),
|
||||
"Should remove the modal after selecting new recent project"
|
||||
);
|
||||
|
||||
assert!(
|
||||
multi_workspace.workspaces().any(|w| w == &dirty_workspace),
|
||||
"The dirty workspace should still be present in multi-workspace mode"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!multi_workspace.workspace().read(cx).is_edited(),
|
||||
"The active workspace should be the freshly opened one, not dirty"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn open_recent_projects(
|
||||
multi_workspace: &WindowHandle<MultiWorkspace>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Entity<Picker<RecentProjectsDelegate>> {
|
||||
cx.dispatch_action(
|
||||
(*multi_workspace).into(),
|
||||
OpenRecent {
|
||||
create_new_window: false,
|
||||
},
|
||||
);
|
||||
multi_workspace
|
||||
.update(cx, |multi_workspace, _, cx| {
|
||||
multi_workspace
|
||||
.workspace()
|
||||
.read(cx)
|
||||
.active_modal::<RecentProjects>(cx)
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.picker
|
||||
.clone()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
|
||||
let app_state = init_test(cx);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
|
@ -11,6 +10,7 @@ use picker::{
|
|||
Picker, PickerDelegate,
|
||||
highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
|
||||
};
|
||||
use project::ProjectGroupKey;
|
||||
use remote::RemoteConnectionOptions;
|
||||
use settings::Settings;
|
||||
use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
|
||||
|
|
@ -33,7 +33,7 @@ pub struct SidebarRecentProjects {
|
|||
impl SidebarRecentProjects {
|
||||
pub fn popover(
|
||||
workspace: WeakEntity<Workspace>,
|
||||
sibling_workspace_ids: HashSet<WorkspaceId>,
|
||||
window_project_groups: Vec<ProjectGroupKey>,
|
||||
_focus_handle: FocusHandle,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
|
|
@ -45,7 +45,7 @@ impl SidebarRecentProjects {
|
|||
cx.new(|cx| {
|
||||
let delegate = SidebarRecentProjectsDelegate {
|
||||
workspace,
|
||||
sibling_workspace_ids,
|
||||
window_project_groups,
|
||||
workspaces: Vec::new(),
|
||||
filtered_workspaces: Vec::new(),
|
||||
selected_index: 0,
|
||||
|
|
@ -116,7 +116,7 @@ impl Render for SidebarRecentProjects {
|
|||
|
||||
pub struct SidebarRecentProjectsDelegate {
|
||||
workspace: WeakEntity<Workspace>,
|
||||
sibling_workspace_ids: HashSet<WorkspaceId>,
|
||||
window_project_groups: Vec<ProjectGroupKey>,
|
||||
workspaces: Vec<(
|
||||
WorkspaceId,
|
||||
SerializedWorkspaceLocation,
|
||||
|
|
@ -207,8 +207,12 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
|
|||
.workspaces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (id, _, _, _))| {
|
||||
Some(*id) != current_workspace_id && !self.sibling_workspace_ids.contains(id)
|
||||
.filter(|(_, (id, _, paths, _))| {
|
||||
Some(*id) != current_workspace_id
|
||||
&& !self
|
||||
.window_project_groups
|
||||
.iter()
|
||||
.any(|key| key.path_list() == paths)
|
||||
})
|
||||
.map(|(id, (_, _, paths, _))| {
|
||||
let combined_string = paths
|
||||
|
|
|
|||
|
|
@ -43,10 +43,10 @@ use ui::{
|
|||
use util::ResultExt as _;
|
||||
use util::path_list::{PathList, SerializedPathList};
|
||||
use workspace::{
|
||||
AddFolderToProject, CloseWindow, FocusWorkspaceSidebar, MoveWorkspaceToNewWindow,
|
||||
MultiWorkspace, MultiWorkspaceEvent, NextProject, NextThread, Open, PreviousProject,
|
||||
PreviousThread, ShowFewerThreads, ShowMoreThreads, Sidebar as WorkspaceSidebar, SidebarSide,
|
||||
ToggleWorkspaceSidebar, Workspace, WorkspaceId, sidebar_side_context_menu,
|
||||
AddFolderToProject, CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent,
|
||||
NextProject, NextThread, Open, PreviousProject, PreviousThread, ShowFewerThreads,
|
||||
ShowMoreThreads, Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar, Workspace,
|
||||
sidebar_side_context_menu,
|
||||
};
|
||||
|
||||
use zed_actions::OpenRecent;
|
||||
|
|
@ -220,11 +220,13 @@ enum ListEntry {
|
|||
worktrees: Vec<WorktreeInfo>,
|
||||
},
|
||||
/// A convenience row for starting a new thread. Shown when a project group
|
||||
/// has no threads, or when the active workspace contains linked worktrees
|
||||
/// with no threads for that specific worktree set.
|
||||
/// has no threads, or when an open linked worktree workspace has no threads.
|
||||
/// When `workspace` is `Some`, this entry is for a specific linked worktree
|
||||
/// workspace and can be dismissed (removing that workspace).
|
||||
NewThread {
|
||||
key: project::ProjectGroupKey,
|
||||
worktrees: Vec<WorktreeInfo>,
|
||||
workspace: Option<Entity<Workspace>>,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +238,48 @@ impl ListEntry {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reachable_workspaces<'a>(
|
||||
&'a self,
|
||||
multi_workspace: &'a workspace::MultiWorkspace,
|
||||
cx: &'a App,
|
||||
) -> Vec<Entity<Workspace>> {
|
||||
match self {
|
||||
ListEntry::Thread(thread) => match &thread.workspace {
|
||||
ThreadEntryWorkspace::Open(ws) => vec![ws.clone()],
|
||||
ThreadEntryWorkspace::Closed(_) => Vec::new(),
|
||||
},
|
||||
ListEntry::DraftThread { .. } => {
|
||||
vec![multi_workspace.workspace().clone()]
|
||||
}
|
||||
ListEntry::ProjectHeader { key, .. } => {
|
||||
// The header only activates the main worktree workspace
|
||||
// (the one whose root paths match the group key's path list).
|
||||
multi_workspace
|
||||
.workspaces()
|
||||
.find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list())
|
||||
.cloned()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
ListEntry::NewThread { key, workspace, .. } => {
|
||||
// When the NewThread entry is for a specific linked worktree
|
||||
// workspace, that workspace is reachable. Otherwise fall back
|
||||
// to the main worktree workspace.
|
||||
if let Some(ws) = workspace {
|
||||
vec![ws.clone()]
|
||||
} else {
|
||||
multi_workspace
|
||||
.workspaces()
|
||||
.find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *key.path_list())
|
||||
.cloned()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ThreadEntry> for ListEntry {
|
||||
|
|
@ -667,13 +711,10 @@ impl Sidebar {
|
|||
result
|
||||
}
|
||||
|
||||
/// Finds an open workspace whose project group key matches the given path list.
|
||||
/// Finds the main worktree workspace for a project group.
|
||||
fn workspace_for_group(&self, path_list: &PathList, cx: &App) -> Option<Entity<Workspace>> {
|
||||
let mw = self.multi_workspace.upgrade()?;
|
||||
let mw = mw.read(cx);
|
||||
mw.workspaces()
|
||||
.find(|ws| ws.read(cx).project_group_key(cx).path_list() == path_list)
|
||||
.cloned()
|
||||
mw.read(cx).workspace_for_paths(path_list, cx)
|
||||
}
|
||||
|
||||
/// Opens a new workspace for a group that has no open workspaces.
|
||||
|
|
@ -1065,56 +1106,51 @@ impl Sidebar {
|
|||
}
|
||||
}
|
||||
|
||||
// Emit a NewThread entry when:
|
||||
// 1. The group has zero threads (convenient affordance).
|
||||
// 2. The active workspace has linked worktrees but no threads
|
||||
// for the active workspace's specific set of worktrees.
|
||||
// Emit NewThread entries:
|
||||
// 1. When the group has zero threads (convenient affordance).
|
||||
// 2. For each open linked worktree workspace in this group
|
||||
// that has no threads (makes the workspace reachable and
|
||||
// dismissable).
|
||||
let group_has_no_threads = threads.is_empty() && !group_workspaces.is_empty();
|
||||
let active_ws_has_threadless_linked_worktrees = is_active
|
||||
&& !is_draft_for_group
|
||||
&& active_workspace.as_ref().is_some_and(|active_ws| {
|
||||
let ws_path_list = workspace_path_list(active_ws, cx);
|
||||
|
||||
if !is_draft_for_group && group_has_no_threads {
|
||||
entries.push(ListEntry::NewThread {
|
||||
key: group_key.clone(),
|
||||
worktrees: Vec::new(),
|
||||
workspace: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit a NewThread for each open linked worktree workspace
|
||||
// that has no threads. Skip the workspace if it's showing
|
||||
// the active draft (it already has a DraftThread entry).
|
||||
if !is_draft_for_group {
|
||||
let thread_store = ThreadMetadataStore::global(cx);
|
||||
for ws in &group_workspaces {
|
||||
let ws_path_list = workspace_path_list(ws, cx);
|
||||
let has_linked_worktrees =
|
||||
worktree_info_from_thread_paths(&ws_path_list, &group_key)
|
||||
.any(|wt| wt.kind == ui::WorktreeKind::Linked);
|
||||
if !has_linked_worktrees {
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
let thread_store = ThreadMetadataStore::global(cx);
|
||||
let has_threads_for_ws = thread_store
|
||||
.read(cx)
|
||||
.entries_for_path(&ws_path_list)
|
||||
.next()
|
||||
.is_some()
|
||||
|| thread_store
|
||||
.read(cx)
|
||||
let store = thread_store.read(cx);
|
||||
let has_threads = store.entries_for_path(&ws_path_list).next().is_some()
|
||||
|| store
|
||||
.entries_for_main_worktree_path(&ws_path_list)
|
||||
.next()
|
||||
.is_some();
|
||||
!has_threads_for_ws
|
||||
});
|
||||
|
||||
if !is_draft_for_group
|
||||
&& (group_has_no_threads || active_ws_has_threadless_linked_worktrees)
|
||||
{
|
||||
let worktrees = if active_ws_has_threadless_linked_worktrees {
|
||||
active_workspace
|
||||
.as_ref()
|
||||
.map(|ws| {
|
||||
worktree_info_from_thread_paths(
|
||||
&workspace_path_list(ws, cx),
|
||||
&group_key,
|
||||
)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
entries.push(ListEntry::NewThread {
|
||||
key: group_key.clone(),
|
||||
worktrees,
|
||||
});
|
||||
if has_threads {
|
||||
continue;
|
||||
}
|
||||
let worktrees: Vec<WorktreeInfo> =
|
||||
worktree_info_from_thread_paths(&ws_path_list, &group_key).collect();
|
||||
entries.push(ListEntry::NewThread {
|
||||
key: group_key.clone(),
|
||||
worktrees,
|
||||
workspace: Some(ws.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let total = threads.len();
|
||||
|
|
@ -1272,9 +1308,11 @@ impl Sidebar {
|
|||
ListEntry::DraftThread { worktrees, .. } => {
|
||||
self.render_draft_thread(ix, is_active, worktrees, is_selected, cx)
|
||||
}
|
||||
ListEntry::NewThread { key, worktrees, .. } => {
|
||||
self.render_new_thread(ix, key, worktrees, is_selected, cx)
|
||||
}
|
||||
ListEntry::NewThread {
|
||||
key,
|
||||
worktrees,
|
||||
workspace,
|
||||
} => self.render_new_thread(ix, key, worktrees, workspace.as_ref(), is_selected, cx),
|
||||
};
|
||||
|
||||
if is_group_header_after_first {
|
||||
|
|
@ -1388,7 +1426,7 @@ impl Sidebar {
|
|||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.when(!is_active, |this| this.cursor_pointer())
|
||||
.cursor_pointer()
|
||||
.relative()
|
||||
.min_w_0()
|
||||
.w_full()
|
||||
|
|
@ -1493,10 +1531,10 @@ impl Sidebar {
|
|||
},
|
||||
),
|
||||
)
|
||||
.when(!is_active, |this| {
|
||||
.map(|this| {
|
||||
let path_list = path_list.clone();
|
||||
this.cursor_pointer()
|
||||
.hover(|s| s.bg(hover_color))
|
||||
.when(!is_active, |this| this.hover(|s| s.bg(hover_color)))
|
||||
.tooltip(Tooltip::text("Open Workspace"))
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
if let Some(workspace) = this.workspace_for_group(&path_list, cx) {
|
||||
|
|
@ -1545,7 +1583,7 @@ impl Sidebar {
|
|||
let multi_workspace = multi_workspace.clone();
|
||||
let project_group_key = project_group_key.clone();
|
||||
|
||||
let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, cx| {
|
||||
let menu = ContextMenu::build_persistent(window, cx, move |menu, _window, _cx| {
|
||||
let mut menu = menu
|
||||
.header("Project Folders")
|
||||
.end_slot_action(Box::new(menu::EndSlot));
|
||||
|
|
@ -1598,42 +1636,15 @@ impl Sidebar {
|
|||
},
|
||||
);
|
||||
|
||||
let group_count = multi_workspace
|
||||
.upgrade()
|
||||
.map_or(0, |mw| mw.read(cx).project_group_keys().count());
|
||||
let menu = if group_count > 1 {
|
||||
let project_group_key = project_group_key.clone();
|
||||
let multi_workspace = multi_workspace.clone();
|
||||
menu.entry(
|
||||
"Move to New Window",
|
||||
Some(Box::new(MoveWorkspaceToNewWindow)),
|
||||
move |window, cx| {
|
||||
multi_workspace
|
||||
.update(cx, |multi_workspace, cx| {
|
||||
multi_workspace.move_project_group_to_new_window(
|
||||
&project_group_key,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
},
|
||||
)
|
||||
} else {
|
||||
menu
|
||||
};
|
||||
|
||||
let project_group_key = project_group_key.clone();
|
||||
let multi_workspace = multi_workspace.clone();
|
||||
menu.separator()
|
||||
.entry("Remove Project", None, move |window, cx| {
|
||||
multi_workspace
|
||||
.update(cx, |multi_workspace, cx| {
|
||||
multi_workspace.remove_project_group(
|
||||
&project_group_key,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
multi_workspace
|
||||
.remove_project_group(&project_group_key, window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
|
|
@ -1968,9 +1979,12 @@ impl Sidebar {
|
|||
ListEntry::DraftThread { .. } => {
|
||||
// Already active — nothing to do.
|
||||
}
|
||||
ListEntry::NewThread { key, .. } => {
|
||||
ListEntry::NewThread { key, workspace, .. } => {
|
||||
let path_list = key.path_list().clone();
|
||||
if let Some(workspace) = self.workspace_for_group(&path_list, cx) {
|
||||
if let Some(workspace) = workspace
|
||||
.clone()
|
||||
.or_else(|| self.workspace_for_group(&path_list, cx))
|
||||
{
|
||||
self.create_new_thread(&workspace, window, cx);
|
||||
} else {
|
||||
self.open_workspace_for_group(&path_list, window, cx);
|
||||
|
|
@ -2360,114 +2374,190 @@ impl Sidebar {
|
|||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(session_id, cx));
|
||||
let thread_folder_paths = ThreadMetadataStore::global(cx)
|
||||
.read(cx)
|
||||
.entry(session_id)
|
||||
.map(|m| m.folder_paths.clone());
|
||||
|
||||
// If we're archiving the currently focused thread, move focus to the
|
||||
// nearest thread within the same project group. We never cross group
|
||||
// boundaries — if the group has no other threads, clear focus and open
|
||||
// a blank new thread in the panel instead.
|
||||
if self
|
||||
// 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).
|
||||
let current_pos = self.contents.entries.iter().position(
|
||||
|entry| matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id),
|
||||
);
|
||||
let neighbor = current_pos.and_then(|pos| {
|
||||
self.contents.entries[pos + 1..]
|
||||
.iter()
|
||||
.chain(self.contents.entries[..pos].iter().rev())
|
||||
.find_map(|entry| match entry {
|
||||
ListEntry::Thread(t) if t.metadata.session_id != *session_id => {
|
||||
let workspace_paths = match &t.workspace {
|
||||
ThreadEntryWorkspace::Open(ws) => {
|
||||
PathList::new(&ws.read(cx).root_paths(cx))
|
||||
}
|
||||
ThreadEntryWorkspace::Closed(paths) => paths.clone(),
|
||||
};
|
||||
Some((t.metadata.clone(), workspace_paths))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
|
||||
// Check if archiving this thread would leave its worktree workspace
|
||||
// with no threads, requiring workspace removal.
|
||||
let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| {
|
||||
if folder_paths.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let remaining = ThreadMetadataStore::global(cx)
|
||||
.read(cx)
|
||||
.entries_for_path(folder_paths)
|
||||
.filter(|t| t.session_id != *session_id)
|
||||
.count();
|
||||
if remaining > 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let multi_workspace = self.multi_workspace.upgrade()?;
|
||||
let workspace = multi_workspace
|
||||
.read(cx)
|
||||
.workspace_for_paths(folder_paths, cx)?;
|
||||
|
||||
// Don't remove the main worktree workspace — the project
|
||||
// header always provides access to it.
|
||||
let group_key = workspace.read(cx).project_group_key(cx);
|
||||
(group_key.path_list() != folder_paths).then_some(workspace)
|
||||
});
|
||||
|
||||
if let Some(workspace_to_remove) = workspace_to_remove {
|
||||
let multi_workspace = self.multi_workspace.upgrade().unwrap();
|
||||
let session_id = session_id.clone();
|
||||
|
||||
// For the workspace-removal fallback, use the neighbor's workspace
|
||||
// paths if available, otherwise fall back to the project group key.
|
||||
let fallback_paths = neighbor
|
||||
.as_ref()
|
||||
.map(|(_, paths)| paths.clone())
|
||||
.unwrap_or_else(|| {
|
||||
workspace_to_remove
|
||||
.read(cx)
|
||||
.project_group_key(cx)
|
||||
.path_list()
|
||||
.clone()
|
||||
});
|
||||
|
||||
let remove_task = multi_workspace.update(cx, |mw, cx| {
|
||||
mw.remove(
|
||||
[workspace_to_remove],
|
||||
move |this, window, cx| {
|
||||
this.find_or_create_local_workspace(fallback_paths, window, cx)
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
|
||||
let thread_folder_paths = thread_folder_paths.clone();
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let removed = remove_task.await?;
|
||||
if removed {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.archive_and_activate(
|
||||
&session_id,
|
||||
neighbor_metadata.as_ref(),
|
||||
thread_folder_paths.as_ref(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
} else {
|
||||
// Simple case: no workspace removal needed.
|
||||
let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
|
||||
self.archive_and_activate(
|
||||
session_id,
|
||||
neighbor_metadata.as_ref(),
|
||||
thread_folder_paths.as_ref(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Archive a thread and activate the nearest neighbor or a draft.
|
||||
fn archive_and_activate(
|
||||
&mut self,
|
||||
session_id: &acp::SessionId,
|
||||
neighbor: Option<&ThreadMetadata>,
|
||||
thread_folder_paths: Option<&PathList>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
ThreadMetadataStore::global(cx).update(cx, |store, cx| {
|
||||
store.archive(session_id, cx);
|
||||
});
|
||||
|
||||
let is_active = self
|
||||
.active_entry
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.is_active_thread(session_id))
|
||||
{
|
||||
let current_pos = self.contents.entries.iter().position(|entry| {
|
||||
matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id)
|
||||
});
|
||||
.is_some_and(|e| e.is_active_thread(session_id));
|
||||
|
||||
// Find the workspace that owns this thread's project group by
|
||||
// walking backwards to the nearest ProjectHeader and looking up
|
||||
// an open workspace for that group's path_list.
|
||||
let group_workspace = current_pos.and_then(|pos| {
|
||||
let path_list =
|
||||
self.contents.entries[..pos]
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|e| match e {
|
||||
ListEntry::ProjectHeader { key, .. } => Some(key.path_list()),
|
||||
_ => None,
|
||||
})?;
|
||||
self.workspace_for_group(path_list, cx)
|
||||
});
|
||||
|
||||
let next_thread = current_pos.and_then(|pos| {
|
||||
let group_start = self.contents.entries[..pos]
|
||||
.iter()
|
||||
.rposition(|e| matches!(e, ListEntry::ProjectHeader { .. }))
|
||||
.map_or(0, |i| i + 1);
|
||||
let group_end = self.contents.entries[pos + 1..]
|
||||
.iter()
|
||||
.position(|e| matches!(e, ListEntry::ProjectHeader { .. }))
|
||||
.map_or(self.contents.entries.len(), |i| pos + 1 + i);
|
||||
|
||||
let above = self.contents.entries[group_start..pos]
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|entry| {
|
||||
if let ListEntry::Thread(t) = entry {
|
||||
Some(t)
|
||||
} else {
|
||||
None
|
||||
if !is_active {
|
||||
// The user is looking at a different thread/draft. Clear the
|
||||
// archived thread from its workspace's panel so that switching
|
||||
// to that workspace later doesn't show a stale thread.
|
||||
if let Some(folder_paths) = thread_folder_paths {
|
||||
if let Some(workspace) = self
|
||||
.multi_workspace
|
||||
.upgrade()
|
||||
.and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, cx))
|
||||
{
|
||||
if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
|
||||
let panel_shows_archived = panel
|
||||
.read(cx)
|
||||
.active_conversation_view()
|
||||
.and_then(|cv| cv.read(cx).parent_id(cx))
|
||||
.is_some_and(|id| id == *session_id);
|
||||
if panel_shows_archived {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.clear_active_thread(window, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
above.or_else(|| {
|
||||
self.contents.entries[pos + 1..group_end]
|
||||
.iter()
|
||||
.find_map(|entry| {
|
||||
if let ListEntry::Thread(t) = entry {
|
||||
Some(t)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
if let Some(next) = next_thread {
|
||||
let next_metadata = next.metadata.clone();
|
||||
// Use the thread's own workspace when it has one open (e.g. an absorbed
|
||||
// linked worktree thread that appears under the main workspace's header
|
||||
// but belongs to its own workspace). Loading into the wrong panel binds
|
||||
// the thread to the wrong project, which corrupts its stored folder_paths
|
||||
// when metadata is saved via ThreadMetadata::from_thread.
|
||||
let target_workspace = match &next.workspace {
|
||||
ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
|
||||
ThreadEntryWorkspace::Closed(_) => group_workspace,
|
||||
};
|
||||
if let Some(ref ws) = target_workspace {
|
||||
self.active_entry = Some(ActiveEntry::Thread {
|
||||
session_id: next_metadata.session_id.clone(),
|
||||
workspace: ws.clone(),
|
||||
});
|
||||
}
|
||||
self.record_thread_access(&next_metadata.session_id);
|
||||
|
||||
if let Some(workspace) = target_workspace {
|
||||
if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
|
||||
agent_panel.update(cx, |panel, cx| {
|
||||
panel.load_agent_thread(
|
||||
Agent::from(next_metadata.agent_id.clone()),
|
||||
next_metadata.session_id.clone(),
|
||||
Some(next_metadata.folder_paths.clone()),
|
||||
Some(next_metadata.title.clone()),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Some(workspace) = &group_workspace {
|
||||
self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
|
||||
if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
|
||||
agent_panel.update(cx, |panel, cx| {
|
||||
panel.new_thread(&NewThread, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to activate the neighbor thread. If its workspace is open,
|
||||
// tell the panel to load it. `rebuild_contents` will reconcile
|
||||
// `active_entry` once the thread finishes loading.
|
||||
if let Some(metadata) = neighbor {
|
||||
if let Some(workspace) = self
|
||||
.multi_workspace
|
||||
.upgrade()
|
||||
.and_then(|mw| mw.read(cx).workspace_for_paths(&metadata.folder_paths, cx))
|
||||
{
|
||||
Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No neighbor or its workspace isn't open — fall back to a new
|
||||
// draft on the active workspace so the user has something to work with.
|
||||
if let Some(workspace) = self.active_entry_workspace().cloned() {
|
||||
if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.new_thread(&NewThread, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2480,16 +2570,45 @@ impl Sidebar {
|
|||
let Some(ix) = self.selection else {
|
||||
return;
|
||||
};
|
||||
let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else {
|
||||
return;
|
||||
};
|
||||
match thread.status {
|
||||
AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => return,
|
||||
AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
|
||||
match self.contents.entries.get(ix) {
|
||||
Some(ListEntry::Thread(thread)) => {
|
||||
match thread.status {
|
||||
AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation => {
|
||||
return;
|
||||
}
|
||||
AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
|
||||
}
|
||||
let session_id = thread.metadata.session_id.clone();
|
||||
self.archive_thread(&session_id, window, cx);
|
||||
}
|
||||
Some(ListEntry::NewThread {
|
||||
workspace: Some(workspace),
|
||||
..
|
||||
}) => {
|
||||
self.remove_worktree_workspace(workspace.clone(), window, cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let session_id = thread.metadata.session_id.clone();
|
||||
self.archive_thread(&session_id, window, cx)
|
||||
fn remove_worktree_workspace(
|
||||
&mut self,
|
||||
workspace: Entity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(multi_workspace) = self.multi_workspace.upgrade() {
|
||||
multi_workspace
|
||||
.update(cx, |mw, cx| {
|
||||
mw.remove(
|
||||
[workspace],
|
||||
|this, _window, _cx| gpui::Task::ready(Ok(this.workspace().clone())),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn record_thread_access(&mut self, session_id: &acp::SessionId) {
|
||||
|
|
@ -2933,14 +3052,9 @@ impl Sidebar {
|
|||
.map(|w| w.read(cx).focus_handle(cx))
|
||||
.unwrap_or_else(|| cx.focus_handle());
|
||||
|
||||
let sibling_workspace_ids: HashSet<WorkspaceId> = multi_workspace
|
||||
let window_project_groups: Vec<ProjectGroupKey> = multi_workspace
|
||||
.as_ref()
|
||||
.map(|mw| {
|
||||
mw.read(cx)
|
||||
.workspaces()
|
||||
.filter_map(|ws| ws.read(cx).database_id())
|
||||
.collect()
|
||||
})
|
||||
.map(|mw| mw.read(cx).project_group_keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let popover_handle = self.recent_projects_popover_handle.clone();
|
||||
|
|
@ -2951,7 +3065,7 @@ impl Sidebar {
|
|||
workspace.as_ref().map(|ws| {
|
||||
SidebarRecentProjects::popover(
|
||||
ws.clone(),
|
||||
sibling_workspace_ids.clone(),
|
||||
window_project_groups.clone(),
|
||||
focus_handle.clone(),
|
||||
window,
|
||||
cx,
|
||||
|
|
@ -3274,30 +3388,6 @@ impl Sidebar {
|
|||
self.create_new_thread(&workspace, window, cx);
|
||||
}
|
||||
|
||||
fn on_move_workspace_to_new_window(
|
||||
&mut self,
|
||||
_: &MoveWorkspaceToNewWindow,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let group_count = multi_workspace.read(cx).project_group_keys().count();
|
||||
if group_count <= 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(active_key) = self.active_project_group_key(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
multi_workspace.update(cx, |multi_workspace, cx| {
|
||||
multi_workspace.move_project_group_to_new_window(&active_key, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn render_draft_thread(
|
||||
&self,
|
||||
ix: usize,
|
||||
|
|
@ -3345,6 +3435,7 @@ impl Sidebar {
|
|||
ix: usize,
|
||||
key: &ProjectGroupKey,
|
||||
worktrees: &[WorktreeInfo],
|
||||
workspace: Option<&Entity<Workspace>>,
|
||||
is_selected: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
|
|
@ -3353,7 +3444,7 @@ impl Sidebar {
|
|||
|
||||
let id = SharedString::from(format!("new-thread-btn-{}", ix));
|
||||
|
||||
let thread_item = ThreadItem::new(id, label)
|
||||
let mut thread_item = ThreadItem::new(id, label)
|
||||
.icon(IconName::Plus)
|
||||
.icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
|
||||
.worktrees(
|
||||
|
|
@ -3378,6 +3469,20 @@ impl Sidebar {
|
|||
}
|
||||
}));
|
||||
|
||||
// Linked worktree NewThread entries can be dismissed, which removes
|
||||
// the workspace from the multi-workspace.
|
||||
if let Some(workspace) = workspace.cloned() {
|
||||
thread_item = thread_item.action_slot(
|
||||
IconButton::new("close-worktree-workspace", IconName::Close)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Close Workspace"))
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.remove_worktree_workspace(workspace.clone(), window, cx);
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
thread_item.into_any_element()
|
||||
}
|
||||
|
||||
|
|
@ -3845,10 +3950,6 @@ impl WorkspaceSidebar for Sidebar {
|
|||
self.cycle_thread_impl(forward, window, cx);
|
||||
}
|
||||
|
||||
fn move_workspace_to_new_window(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.on_move_workspace_to_new_window(&MoveWorkspaceToNewWindow, window, cx);
|
||||
}
|
||||
|
||||
fn serialized_state(&self, _cx: &App) -> Option<String> {
|
||||
let serialized = SerializedSidebar {
|
||||
width: Some(f32::from(self.width)),
|
||||
|
|
@ -3951,7 +4052,6 @@ impl Render for Sidebar {
|
|||
.on_action(cx.listener(Self::on_show_more_threads))
|
||||
.on_action(cx.listener(Self::on_show_fewer_threads))
|
||||
.on_action(cx.listener(Self::on_new_thread))
|
||||
.on_action(cx.listener(Self::on_move_workspace_to_new_window))
|
||||
.on_action(cx.listener(|this, _: &OpenRecent, window, cx| {
|
||||
this.recent_projects_popover_handle.toggle(window, cx);
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -522,18 +522,6 @@ async fn test_workspace_lifecycle(cx: &mut TestAppContext) {
|
|||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [project-a]", " Thread A1",]
|
||||
);
|
||||
|
||||
// Remove the second workspace
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
let workspace = mw.workspaces().nth(1).cloned().unwrap();
|
||||
mw.remove(&workspace, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [project-a]", " Thread A1"]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
|
@ -4133,6 +4121,129 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon
|
|||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_archive_last_worktree_thread_removes_workspace(cx: &mut TestAppContext) {
|
||||
// When the last non-archived thread for a linked worktree is archived,
|
||||
// the linked worktree workspace should be removed from the multi-workspace.
|
||||
// The main worktree workspace should remain (it's always reachable via
|
||||
// the project header).
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
|
||||
fs.insert_tree(
|
||||
"/project",
|
||||
serde_json::json!({
|
||||
".git": {
|
||||
"worktrees": {
|
||||
"feature-a": {
|
||||
"commondir": "../../",
|
||||
"HEAD": "ref: refs/heads/feature-a",
|
||||
},
|
||||
},
|
||||
},
|
||||
"src": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
fs.insert_tree(
|
||||
"/wt-feature-a",
|
||||
serde_json::json!({
|
||||
".git": "gitdir: /project/.git/worktrees/feature-a",
|
||||
"src": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
fs.add_linked_worktree_for_repo(
|
||||
Path::new("/project/.git"),
|
||||
false,
|
||||
git::repository::Worktree {
|
||||
path: PathBuf::from("/wt-feature-a"),
|
||||
ref_name: Some("refs/heads/feature-a".into()),
|
||||
sha: "abc".into(),
|
||||
is_main: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
|
||||
|
||||
let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
|
||||
let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
|
||||
|
||||
main_project
|
||||
.update(cx, |p, cx| p.git_scans_complete(cx))
|
||||
.await;
|
||||
worktree_project
|
||||
.update(cx, |p, cx| p.git_scans_complete(cx))
|
||||
.await;
|
||||
|
||||
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 _worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(worktree_project.clone(), window, cx)
|
||||
});
|
||||
|
||||
// Save a thread for the main project.
|
||||
save_thread_metadata(
|
||||
acp::SessionId::new(Arc::from("main-thread")),
|
||||
"Main Thread".into(),
|
||||
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
|
||||
None,
|
||||
&main_project,
|
||||
cx,
|
||||
);
|
||||
|
||||
// Save a thread for the linked worktree.
|
||||
let wt_thread_id = acp::SessionId::new(Arc::from("worktree-thread"));
|
||||
save_thread_metadata(
|
||||
wt_thread_id.clone(),
|
||||
"Worktree Thread".into(),
|
||||
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
|
||||
None,
|
||||
&worktree_project,
|
||||
cx,
|
||||
);
|
||||
cx.run_until_parked();
|
||||
|
||||
multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
|
||||
cx.run_until_parked();
|
||||
|
||||
// Should have 2 workspaces.
|
||||
assert_eq!(
|
||||
multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
|
||||
2,
|
||||
"should start with 2 workspaces (main + linked worktree)"
|
||||
);
|
||||
|
||||
// Archive the worktree thread (the only thread for /wt-feature-a).
|
||||
sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
|
||||
sidebar.archive_thread(&wt_thread_id, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// The linked worktree workspace should have been removed.
|
||||
assert_eq!(
|
||||
multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
|
||||
1,
|
||||
"linked worktree workspace should be removed after archiving its last thread"
|
||||
);
|
||||
|
||||
// The main thread should still be visible.
|
||||
let entries = visible_entries_as_strings(&sidebar, cx);
|
||||
assert!(
|
||||
entries.iter().any(|e| e.contains("Main Thread")),
|
||||
"main thread should still be visible: {entries:?}"
|
||||
);
|
||||
assert!(
|
||||
!entries.iter().any(|e| e.contains("Worktree Thread")),
|
||||
"archived worktree thread should not be visible: {entries:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut TestAppContext) {
|
||||
// When a multi-root workspace (e.g. [/other, /project]) shares a
|
||||
|
|
@ -4611,6 +4722,176 @@ async fn test_archive_thread_keeps_metadata_but_hides_from_sidebar(cx: &mut Test
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
|
||||
// Tests two archive scenarios:
|
||||
// 1. Archiving a thread in a non-active workspace leaves active_entry
|
||||
// as the current draft.
|
||||
// 2. Archiving the thread the user is looking at falls back to a draft
|
||||
// on the same workspace.
|
||||
agent_ui::test_support::init_test(cx);
|
||||
cx.update(|cx| {
|
||||
ThreadStore::init_global(cx);
|
||||
ThreadMetadataStore::init_global(cx);
|
||||
language_model::LanguageModelRegistry::test(cx);
|
||||
prompt_store::init(cx);
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
|
||||
.await;
|
||||
fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
|
||||
.await;
|
||||
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
|
||||
|
||||
let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
|
||||
let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
|
||||
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
|
||||
let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
|
||||
|
||||
let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(project_b.clone(), window, cx)
|
||||
});
|
||||
let panel_b = add_agent_panel(&workspace_b, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
// --- Scenario 1: archive a thread in the non-active workspace ---
|
||||
|
||||
// Create a thread in project-a (non-active — project-b is active).
|
||||
let connection = acp_thread::StubAgentConnection::new();
|
||||
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
|
||||
acp::ContentChunk::new("Done".into()),
|
||||
)]);
|
||||
agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
|
||||
agent_ui::test_support::send_message(&panel_a, cx);
|
||||
let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.archive_thread(&thread_a, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// active_entry should still be a draft on workspace_b (the active one).
|
||||
sidebar.read_with(cx, |sidebar, _| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b),
|
||||
"expected Draft(workspace_b) after archiving non-active thread, got: {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
});
|
||||
|
||||
// --- Scenario 2: archive the thread the user is looking at ---
|
||||
|
||||
// Create a thread in project-b (the active workspace) and verify it
|
||||
// becomes the active entry.
|
||||
let connection = acp_thread::StubAgentConnection::new();
|
||||
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
|
||||
acp::ContentChunk::new("Done".into()),
|
||||
)]);
|
||||
agent_ui::test_support::open_thread_with_connection(&panel_b, connection, cx);
|
||||
agent_ui::test_support::send_message(&panel_b, cx);
|
||||
let thread_b = agent_ui::test_support::active_session_id(&panel_b, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.read_with(cx, |sidebar, _| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id, .. }) if *session_id == thread_b),
|
||||
"expected active_entry to be Thread({thread_b}), got: {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
});
|
||||
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.archive_thread(&thread_b, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Should fall back to a draft on the same workspace.
|
||||
sidebar.read_with(cx, |sidebar, _| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_b),
|
||||
"expected Draft(workspace_b) after archiving active thread, got: {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut TestAppContext) {
|
||||
// When a thread is archived while the user is in a different workspace,
|
||||
// the archiving code clears the thread from its panel (via
|
||||
// `clear_active_thread`). Switching back to that workspace should show
|
||||
// a draft, not the archived thread.
|
||||
agent_ui::test_support::init_test(cx);
|
||||
cx.update(|cx| {
|
||||
ThreadStore::init_global(cx);
|
||||
ThreadMetadataStore::init_global(cx);
|
||||
language_model::LanguageModelRegistry::test(cx);
|
||||
prompt_store::init(cx);
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
|
||||
.await;
|
||||
fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
|
||||
.await;
|
||||
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
|
||||
|
||||
let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
|
||||
let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
|
||||
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
|
||||
let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
|
||||
|
||||
let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(project_b.clone(), window, cx)
|
||||
});
|
||||
let _panel_b = add_agent_panel(&workspace_b, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
// Create a thread in project-a's panel (currently non-active).
|
||||
let connection = acp_thread::StubAgentConnection::new();
|
||||
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
|
||||
acp::ContentChunk::new("Done".into()),
|
||||
)]);
|
||||
agent_ui::test_support::open_thread_with_connection(&panel_a, connection, cx);
|
||||
agent_ui::test_support::send_message(&panel_a, cx);
|
||||
let thread_a = agent_ui::test_support::active_session_id(&panel_a, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
// Archive it while project-b is active.
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.archive_thread(&thread_a, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Switch back to project-a. Its panel was cleared during archiving,
|
||||
// so active_entry should be Draft.
|
||||
let workspace_a =
|
||||
multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.activate(workspace_a.clone(), window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.update_in(cx, |sidebar, _window, cx| {
|
||||
sidebar.update_entries(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.read_with(cx, |sidebar, _| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == &workspace_a),
|
||||
"expected Draft(workspace_a) after switching to workspace with archived thread, got: {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppContext) {
|
||||
let project = init_test_project("/my-project", cx).await;
|
||||
|
|
@ -4673,6 +4954,140 @@ async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppCon
|
|||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_linked_worktree_workspace_reachable_and_dismissable(cx: &mut TestAppContext) {
|
||||
// When a linked worktree is opened as its own workspace and the user
|
||||
// switches away, the workspace must still be reachable from a NewThread
|
||||
// sidebar entry. Pressing RemoveSelectedThread (shift-backspace) on that
|
||||
// entry should remove the workspace.
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
|
||||
fs.insert_tree(
|
||||
"/project",
|
||||
serde_json::json!({
|
||||
".git": {
|
||||
"worktrees": {
|
||||
"feature-a": {
|
||||
"commondir": "../../",
|
||||
"HEAD": "ref: refs/heads/feature-a",
|
||||
},
|
||||
},
|
||||
},
|
||||
"src": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
fs.insert_tree(
|
||||
"/wt-feature-a",
|
||||
serde_json::json!({
|
||||
".git": "gitdir: /project/.git/worktrees/feature-a",
|
||||
"src": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
fs.add_linked_worktree_for_repo(
|
||||
Path::new("/project/.git"),
|
||||
false,
|
||||
git::repository::Worktree {
|
||||
path: PathBuf::from("/wt-feature-a"),
|
||||
ref_name: Some("refs/heads/feature-a".into()),
|
||||
sha: "aaa".into(),
|
||||
is_main: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
|
||||
|
||||
let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
|
||||
let worktree_project = project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
|
||||
|
||||
main_project
|
||||
.update(cx, |p, cx| p.git_scans_complete(cx))
|
||||
.await;
|
||||
worktree_project
|
||||
.update(cx, |p, cx| p.git_scans_complete(cx))
|
||||
.await;
|
||||
|
||||
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);
|
||||
|
||||
// Open the linked worktree as a separate workspace (simulates cmd-o).
|
||||
let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(worktree_project.clone(), window, cx)
|
||||
});
|
||||
add_agent_panel(&worktree_workspace, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
// Switch back to the main workspace.
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
let main_ws = mw.workspaces().next().unwrap().clone();
|
||||
mw.activate(main_ws, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.update_in(cx, |sidebar, _window, cx| {
|
||||
sidebar.update_entries(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// The linked worktree workspace must be reachable from some sidebar entry.
|
||||
let worktree_ws_id = worktree_workspace.entity_id();
|
||||
let reachable: Vec<gpui::EntityId> = sidebar.read_with(cx, |sidebar, cx| {
|
||||
let mw = multi_workspace.read(cx);
|
||||
sidebar
|
||||
.contents
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|entry| entry.reachable_workspaces(mw, cx))
|
||||
.map(|ws| ws.entity_id())
|
||||
.collect()
|
||||
});
|
||||
assert!(
|
||||
reachable.contains(&worktree_ws_id),
|
||||
"linked worktree workspace should be reachable, but reachable are: {reachable:?}"
|
||||
);
|
||||
|
||||
// Find the NewThread entry for the linked worktree and dismiss it.
|
||||
let new_thread_ix = sidebar.read_with(cx, |sidebar, _| {
|
||||
sidebar
|
||||
.contents
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
ListEntry::NewThread {
|
||||
workspace: Some(_),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("expected a NewThread entry for the linked worktree")
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
|
||||
2
|
||||
);
|
||||
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.selection = Some(new_thread_ix);
|
||||
sidebar.remove_selected_thread(&RemoveSelectedThread, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
multi_workspace.read_with(cx, |mw, _| mw.workspaces().count()),
|
||||
1,
|
||||
"linked worktree workspace should be removed after dismissing NewThread entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_linked_worktree_workspace_shows_main_worktree_threads(cx: &mut TestAppContext) {
|
||||
// When only a linked worktree workspace is open (not the main repo),
|
||||
|
|
@ -5051,35 +5466,25 @@ mod property_test {
|
|||
workspace_counter: u32,
|
||||
worktree_counter: u32,
|
||||
saved_thread_ids: Vec<acp::SessionId>,
|
||||
workspace_paths: Vec<String>,
|
||||
main_repo_indices: Vec<usize>,
|
||||
unopened_worktrees: Vec<UnopenedWorktree>,
|
||||
}
|
||||
|
||||
impl TestState {
|
||||
fn new(fs: Arc<FakeFs>, initial_workspace_path: String) -> Self {
|
||||
fn new(fs: Arc<FakeFs>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
thread_counter: 0,
|
||||
workspace_counter: 1,
|
||||
worktree_counter: 0,
|
||||
saved_thread_ids: Vec::new(),
|
||||
workspace_paths: vec![initial_workspace_path],
|
||||
main_repo_indices: vec![0],
|
||||
unopened_worktrees: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_thread_id(&mut self) -> acp::SessionId {
|
||||
fn next_metadata_only_thread_id(&mut self) -> acp::SessionId {
|
||||
let id = self.thread_counter;
|
||||
self.thread_counter += 1;
|
||||
let session_id = acp::SessionId::new(Arc::from(format!("prop-thread-{id}")));
|
||||
self.saved_thread_ids.push(session_id.clone());
|
||||
session_id
|
||||
}
|
||||
|
||||
fn remove_thread(&mut self, index: usize) -> acp::SessionId {
|
||||
self.saved_thread_ids.remove(index)
|
||||
acp::SessionId::new(Arc::from(format!("prop-thread-{id}")))
|
||||
}
|
||||
|
||||
fn next_workspace_path(&mut self) -> String {
|
||||
|
|
@ -5097,99 +5502,81 @@ mod property_test {
|
|||
|
||||
#[derive(Debug)]
|
||||
enum Operation {
|
||||
SaveThread { workspace_index: usize },
|
||||
SaveThread { project_group_index: usize },
|
||||
SaveWorktreeThread { worktree_index: usize },
|
||||
DeleteThread { index: usize },
|
||||
ToggleAgentPanel,
|
||||
CreateDraftThread,
|
||||
AddWorkspace,
|
||||
OpenWorktreeAsWorkspace { worktree_index: usize },
|
||||
RemoveWorkspace { index: usize },
|
||||
SwitchWorkspace { index: usize },
|
||||
AddLinkedWorktree { workspace_index: usize },
|
||||
AddProject { use_worktree: bool },
|
||||
ArchiveThread { index: usize },
|
||||
SwitchToThread { index: usize },
|
||||
SwitchToProjectGroup { index: usize },
|
||||
AddLinkedWorktree { project_group_index: usize },
|
||||
}
|
||||
|
||||
// Distribution (out of 20 slots):
|
||||
// SaveThread: 5 slots (~23%)
|
||||
// SaveWorktreeThread: 2 slots (~9%)
|
||||
// DeleteThread: 2 slots (~9%)
|
||||
// ToggleAgentPanel: 2 slots (~9%)
|
||||
// CreateDraftThread: 2 slots (~9%)
|
||||
// AddWorkspace: 1 slot (~5%)
|
||||
// OpenWorktreeAsWorkspace: 1 slot (~5%)
|
||||
// RemoveWorkspace: 1 slot (~5%)
|
||||
// SwitchWorkspace: 2 slots (~9%)
|
||||
// AddLinkedWorktree: 4 slots (~18%)
|
||||
const DISTRIBUTION_SLOTS: u32 = 22;
|
||||
// SaveThread: 5 slots (~25%)
|
||||
// SaveWorktreeThread: 2 slots (~10%)
|
||||
// ToggleAgentPanel: 1 slot (~5%)
|
||||
// CreateDraftThread: 1 slot (~5%)
|
||||
// AddProject: 1 slot (~5%)
|
||||
// ArchiveThread: 2 slots (~10%)
|
||||
// SwitchToThread: 2 slots (~10%)
|
||||
// SwitchToProjectGroup: 2 slots (~10%)
|
||||
// AddLinkedWorktree: 4 slots (~20%)
|
||||
const DISTRIBUTION_SLOTS: u32 = 20;
|
||||
|
||||
impl TestState {
|
||||
fn generate_operation(&self, raw: u32) -> Operation {
|
||||
fn generate_operation(&self, raw: u32, project_group_count: usize) -> Operation {
|
||||
let extra = (raw / DISTRIBUTION_SLOTS) as usize;
|
||||
let workspace_count = self.workspace_paths.len();
|
||||
|
||||
match raw % DISTRIBUTION_SLOTS {
|
||||
0..=4 => Operation::SaveThread {
|
||||
workspace_index: extra % workspace_count,
|
||||
project_group_index: extra % project_group_count,
|
||||
},
|
||||
5..=6 if !self.unopened_worktrees.is_empty() => Operation::SaveWorktreeThread {
|
||||
worktree_index: extra % self.unopened_worktrees.len(),
|
||||
},
|
||||
5..=6 => Operation::SaveThread {
|
||||
workspace_index: extra % workspace_count,
|
||||
project_group_index: extra % project_group_count,
|
||||
},
|
||||
7..=8 if !self.saved_thread_ids.is_empty() => Operation::DeleteThread {
|
||||
7 => Operation::ToggleAgentPanel,
|
||||
8 => Operation::CreateDraftThread,
|
||||
9 => Operation::AddProject {
|
||||
use_worktree: !self.unopened_worktrees.is_empty(),
|
||||
},
|
||||
10..=11 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
|
||||
index: extra % self.saved_thread_ids.len(),
|
||||
},
|
||||
7..=8 => Operation::SaveThread {
|
||||
workspace_index: extra % workspace_count,
|
||||
10..=11 => Operation::AddProject {
|
||||
use_worktree: !self.unopened_worktrees.is_empty(),
|
||||
},
|
||||
9..=10 => Operation::ToggleAgentPanel,
|
||||
11..=12 => Operation::CreateDraftThread,
|
||||
13 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace {
|
||||
worktree_index: extra % self.unopened_worktrees.len(),
|
||||
12..=13 if !self.saved_thread_ids.is_empty() => Operation::SwitchToThread {
|
||||
index: extra % self.saved_thread_ids.len(),
|
||||
},
|
||||
13 => Operation::AddWorkspace,
|
||||
14 if workspace_count > 1 => Operation::RemoveWorkspace {
|
||||
index: extra % workspace_count,
|
||||
12..=13 => Operation::SwitchToProjectGroup {
|
||||
index: extra % project_group_count,
|
||||
},
|
||||
14 => Operation::AddWorkspace,
|
||||
15..=16 => Operation::SwitchWorkspace {
|
||||
index: extra % workspace_count,
|
||||
14..=15 => Operation::SwitchToProjectGroup {
|
||||
index: extra % project_group_count,
|
||||
},
|
||||
17..=21 if !self.main_repo_indices.is_empty() => {
|
||||
let main_index = self.main_repo_indices[extra % self.main_repo_indices.len()];
|
||||
Operation::AddLinkedWorktree {
|
||||
workspace_index: main_index,
|
||||
}
|
||||
}
|
||||
17..=21 => Operation::SaveThread {
|
||||
workspace_index: extra % workspace_count,
|
||||
16..=19 if project_group_count > 0 => Operation::AddLinkedWorktree {
|
||||
project_group_index: extra % project_group_count,
|
||||
},
|
||||
16..=19 => Operation::SaveThread {
|
||||
project_group_index: extra % project_group_count,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn save_thread_to_path(
|
||||
state: &mut TestState,
|
||||
project: &Entity<project::Project>,
|
||||
cx: &mut gpui::VisualTestContext,
|
||||
) {
|
||||
let session_id = state.next_thread_id();
|
||||
let title: SharedString = format!("Thread {}", session_id).into();
|
||||
let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
|
||||
.unwrap()
|
||||
+ chrono::Duration::seconds(state.thread_counter as i64);
|
||||
save_thread_metadata(session_id, title, updated_at, None, project, cx);
|
||||
}
|
||||
|
||||
fn save_thread_to_path_with_main(
|
||||
state: &mut TestState,
|
||||
path_list: PathList,
|
||||
main_worktree_paths: PathList,
|
||||
cx: &mut gpui::VisualTestContext,
|
||||
) {
|
||||
let session_id = state.next_thread_id();
|
||||
let session_id = state.next_metadata_only_thread_id();
|
||||
let title: SharedString = format!("Thread {}", session_id).into();
|
||||
let updated_at = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
|
||||
.unwrap()
|
||||
|
|
@ -5215,20 +5602,48 @@ mod property_test {
|
|||
operation: Operation,
|
||||
state: &mut TestState,
|
||||
multi_workspace: &Entity<MultiWorkspace>,
|
||||
_sidebar: &Entity<Sidebar>,
|
||||
sidebar: &Entity<Sidebar>,
|
||||
cx: &mut gpui::VisualTestContext,
|
||||
) {
|
||||
match operation {
|
||||
Operation::SaveThread { workspace_index } => {
|
||||
let project = multi_workspace.read_with(cx, |mw, cx| {
|
||||
mw.workspaces()
|
||||
.nth(workspace_index)
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.project()
|
||||
.clone()
|
||||
Operation::SaveThread {
|
||||
project_group_index,
|
||||
} => {
|
||||
// Find a workspace for this project group and create a real
|
||||
// thread via its agent panel.
|
||||
let (workspace, project) = multi_workspace.read_with(cx, |mw, cx| {
|
||||
let key = mw.project_group_keys().nth(project_group_index).unwrap();
|
||||
let ws = mw
|
||||
.workspaces_for_project_group(key, cx)
|
||||
.next()
|
||||
.unwrap_or(mw.workspace())
|
||||
.clone();
|
||||
let project = ws.read(cx).project().clone();
|
||||
(ws, project)
|
||||
});
|
||||
save_thread_to_path(state, &project, cx);
|
||||
|
||||
let panel =
|
||||
workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
|
||||
if let Some(panel) = panel {
|
||||
let connection = StubAgentConnection::new();
|
||||
connection.set_next_prompt_updates(vec![
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
|
||||
"Done".into(),
|
||||
)),
|
||||
]);
|
||||
open_thread_with_connection(&panel, connection, cx);
|
||||
send_message(&panel, cx);
|
||||
let session_id = active_session_id(&panel, cx);
|
||||
state.saved_thread_ids.push(session_id.clone());
|
||||
|
||||
let title: SharedString = format!("Thread {}", state.thread_counter).into();
|
||||
state.thread_counter += 1;
|
||||
let updated_at =
|
||||
chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2024, 1, 1, 0, 0, 0)
|
||||
.unwrap()
|
||||
+ chrono::Duration::seconds(state.thread_counter as i64);
|
||||
save_thread_metadata(session_id, title, updated_at, None, &project, cx);
|
||||
}
|
||||
}
|
||||
Operation::SaveWorktreeThread { worktree_index } => {
|
||||
let worktree = &state.unopened_worktrees[worktree_index];
|
||||
|
|
@ -5237,13 +5652,7 @@ mod property_test {
|
|||
PathList::new(&[std::path::PathBuf::from(&worktree.main_workspace_path)]);
|
||||
save_thread_to_path_with_main(state, path_list, main_worktree_paths, cx);
|
||||
}
|
||||
Operation::DeleteThread { index } => {
|
||||
let session_id = state.remove_thread(index);
|
||||
cx.update(|_, cx| {
|
||||
ThreadMetadataStore::global(cx)
|
||||
.update(cx, |store, cx| store.delete(session_id, cx));
|
||||
});
|
||||
}
|
||||
|
||||
Operation::ToggleAgentPanel => {
|
||||
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
||||
let panel_open =
|
||||
|
|
@ -5269,18 +5678,26 @@ mod property_test {
|
|||
workspace.focus_panel::<AgentPanel>(window, cx);
|
||||
});
|
||||
}
|
||||
Operation::AddWorkspace => {
|
||||
let path = state.next_workspace_path();
|
||||
state
|
||||
.fs
|
||||
.insert_tree(
|
||||
&path,
|
||||
serde_json::json!({
|
||||
".git": {},
|
||||
"src": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
Operation::AddProject { use_worktree } => {
|
||||
let path = if use_worktree {
|
||||
// Open an existing linked worktree as a project (simulates Cmd+O
|
||||
// on a worktree directory).
|
||||
state.unopened_worktrees.remove(0).path
|
||||
} else {
|
||||
// Create a brand new project.
|
||||
let path = state.next_workspace_path();
|
||||
state
|
||||
.fs
|
||||
.insert_tree(
|
||||
&path,
|
||||
serde_json::json!({
|
||||
".git": {},
|
||||
"src": {},
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
path
|
||||
};
|
||||
let project = project::Project::test(
|
||||
state.fs.clone() as Arc<dyn fs::Fs>,
|
||||
[path.as_ref()],
|
||||
|
|
@ -5288,53 +5705,62 @@ mod property_test {
|
|||
)
|
||||
.await;
|
||||
project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
|
||||
let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(project.clone(), window, cx)
|
||||
});
|
||||
add_agent_panel(&workspace, cx);
|
||||
let new_index = state.workspace_paths.len();
|
||||
state.workspace_paths.push(path);
|
||||
state.main_repo_indices.push(new_index);
|
||||
}
|
||||
Operation::OpenWorktreeAsWorkspace { worktree_index } => {
|
||||
let worktree = state.unopened_worktrees.remove(worktree_index);
|
||||
let project = project::Project::test(
|
||||
state.fs.clone() as Arc<dyn fs::Fs>,
|
||||
[worktree.path.as_ref()],
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
|
||||
let workspace = multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(project.clone(), window, cx)
|
||||
Operation::ArchiveThread { index } => {
|
||||
let session_id = state.saved_thread_ids[index].clone();
|
||||
sidebar.update_in(cx, |sidebar: &mut Sidebar, window, cx| {
|
||||
sidebar.archive_thread(&session_id, window, cx);
|
||||
});
|
||||
add_agent_panel(&workspace, cx);
|
||||
state.workspace_paths.push(worktree.path);
|
||||
cx.run_until_parked();
|
||||
state.saved_thread_ids.remove(index);
|
||||
}
|
||||
Operation::RemoveWorkspace { index } => {
|
||||
let removed = multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
let workspace = mw.workspaces().nth(index).unwrap().clone();
|
||||
mw.remove(&workspace, window, cx)
|
||||
Operation::SwitchToThread { index } => {
|
||||
let session_id = state.saved_thread_ids[index].clone();
|
||||
// Find the thread's position in the sidebar entries and select it.
|
||||
let thread_index = sidebar.read_with(cx, |sidebar, _| {
|
||||
sidebar.contents.entries.iter().position(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
ListEntry::Thread(t) if t.metadata.session_id == session_id
|
||||
)
|
||||
})
|
||||
});
|
||||
if removed {
|
||||
state.workspace_paths.remove(index);
|
||||
state.main_repo_indices.retain(|i| *i != index);
|
||||
for i in &mut state.main_repo_indices {
|
||||
if *i > index {
|
||||
*i -= 1;
|
||||
}
|
||||
}
|
||||
if let Some(ix) = thread_index {
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.selection = Some(ix);
|
||||
sidebar.confirm(&Confirm, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
}
|
||||
Operation::SwitchWorkspace { index } => {
|
||||
let workspace = multi_workspace
|
||||
.read_with(cx, |mw, _| mw.workspaces().nth(index).unwrap().clone());
|
||||
Operation::SwitchToProjectGroup { index } => {
|
||||
let workspace = multi_workspace.read_with(cx, |mw, cx| {
|
||||
let key = mw.project_group_keys().nth(index).unwrap();
|
||||
mw.workspaces_for_project_group(key, cx)
|
||||
.next()
|
||||
.unwrap_or(mw.workspace())
|
||||
.clone()
|
||||
});
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.activate(workspace, window, cx);
|
||||
});
|
||||
}
|
||||
Operation::AddLinkedWorktree { workspace_index } => {
|
||||
let main_path = state.workspace_paths[workspace_index].clone();
|
||||
Operation::AddLinkedWorktree {
|
||||
project_group_index,
|
||||
} => {
|
||||
// Get the main worktree path from the project group key.
|
||||
let main_path = multi_workspace.read_with(cx, |mw, _| {
|
||||
let key = mw.project_group_keys().nth(project_group_index).unwrap();
|
||||
key.path_list()
|
||||
.paths()
|
||||
.first()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
});
|
||||
let dot_git = format!("{}/.git", main_path);
|
||||
let worktree_name = state.next_worktree_name();
|
||||
let worktree_path = format!("/worktrees/{}", worktree_name);
|
||||
|
|
@ -5378,8 +5804,12 @@ mod property_test {
|
|||
.await;
|
||||
|
||||
// Re-scan the main workspace's project so it discovers the new worktree.
|
||||
let main_workspace = multi_workspace.read_with(cx, |mw, _| {
|
||||
mw.workspaces().nth(workspace_index).unwrap().clone()
|
||||
let main_workspace = multi_workspace.read_with(cx, |mw, cx| {
|
||||
let key = mw.project_group_keys().nth(project_group_index).unwrap();
|
||||
mw.workspaces_for_project_group(key, cx)
|
||||
.next()
|
||||
.unwrap()
|
||||
.clone()
|
||||
});
|
||||
let main_project = main_workspace.read_with(cx, |ws, _| ws.project().clone());
|
||||
main_project
|
||||
|
|
@ -5414,13 +5844,14 @@ mod property_test {
|
|||
}
|
||||
|
||||
fn validate_sidebar_properties(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
|
||||
verify_every_workspace_in_multiworkspace_is_shown(sidebar, cx)?;
|
||||
verify_every_group_in_multiworkspace_is_shown(sidebar, cx)?;
|
||||
verify_all_threads_are_shown(sidebar, cx)?;
|
||||
verify_active_state_matches_current_workspace(sidebar, cx)?;
|
||||
verify_all_workspaces_are_reachable(sidebar, cx)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_every_workspace_in_multiworkspace_is_shown(
|
||||
fn verify_every_group_in_multiworkspace_is_shown(
|
||||
sidebar: &Sidebar,
|
||||
cx: &App,
|
||||
) -> anyhow::Result<()> {
|
||||
|
|
@ -5633,8 +6064,41 @@ mod property_test {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Every workspace in the multi-workspace should be "reachable" from
|
||||
/// the sidebar — meaning there is at least one entry (thread, draft,
|
||||
/// new-thread, or project header) that, when clicked, would activate
|
||||
/// that workspace.
|
||||
fn verify_all_workspaces_are_reachable(sidebar: &Sidebar, cx: &App) -> anyhow::Result<()> {
|
||||
let Some(multi_workspace) = sidebar.multi_workspace.upgrade() else {
|
||||
anyhow::bail!("sidebar should still have an associated multi-workspace");
|
||||
};
|
||||
|
||||
let mw = multi_workspace.read(cx);
|
||||
|
||||
let reachable_workspaces: HashSet<gpui::EntityId> = sidebar
|
||||
.contents
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|entry| entry.reachable_workspaces(mw, cx))
|
||||
.map(|ws| ws.entity_id())
|
||||
.collect();
|
||||
|
||||
let all_workspace_ids: HashSet<gpui::EntityId> =
|
||||
mw.workspaces().map(|ws| ws.entity_id()).collect();
|
||||
|
||||
let unreachable = &all_workspace_ids - &reachable_workspaces;
|
||||
|
||||
anyhow::ensure!(
|
||||
unreachable.is_empty(),
|
||||
"The following workspaces are not reachable from any sidebar entry: {:?}",
|
||||
unreachable,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[gpui::property_test(config = ProptestConfig {
|
||||
cases: 10,
|
||||
cases: 50,
|
||||
..Default::default()
|
||||
})]
|
||||
async fn test_sidebar_invariants(
|
||||
|
|
@ -5648,6 +6112,20 @@ mod property_test {
|
|||
ThreadMetadataStore::init_global(cx);
|
||||
language_model::LanguageModelRegistry::test(cx);
|
||||
prompt_store::init(cx);
|
||||
|
||||
// Auto-add an AgentPanel to every workspace so that implicitly
|
||||
// created workspaces (e.g. from thread activation) also have one.
|
||||
cx.observe_new(
|
||||
|workspace: &mut Workspace,
|
||||
window: Option<&mut Window>,
|
||||
cx: &mut gpui::Context<Workspace>| {
|
||||
if let Some(window) = window {
|
||||
let panel = cx.new(|cx| AgentPanel::test_new(workspace, window, cx));
|
||||
workspace.add_panel(panel, window, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
|
|
@ -5667,13 +6145,15 @@ mod property_test {
|
|||
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
|
||||
let sidebar = setup_sidebar(&multi_workspace, cx);
|
||||
|
||||
let mut state = TestState::new(fs, "/my-project".to_string());
|
||||
let mut state = TestState::new(fs);
|
||||
let mut executed: Vec<String> = Vec::new();
|
||||
|
||||
for &raw_op in &raw_operations {
|
||||
let operation = state.generate_operation(raw_op);
|
||||
let project_group_count =
|
||||
multi_workspace.read_with(cx, |mw, _| mw.project_group_keys().count());
|
||||
let operation = state.generate_operation(raw_op, project_group_count);
|
||||
executed.push(format!("{:?}", operation));
|
||||
perform_operation(operation, &mut state, &multi_workspace, &sidebar, cx).await;
|
||||
cx.run_until_parked();
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorkt
|
|||
use remote::RemoteConnectionOptions;
|
||||
use settings::Settings;
|
||||
use settings::WorktreeId;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use std::sync::Arc;
|
||||
use theme::ActiveTheme;
|
||||
use title_bar_settings::TitleBarSettings;
|
||||
|
|
@ -47,7 +47,7 @@ use ui::{
|
|||
use update_version::UpdateVersion;
|
||||
use util::ResultExt;
|
||||
use workspace::{
|
||||
MultiWorkspace, ToggleWorktreeSecurity, Workspace, WorkspaceId, notifications::NotifyResultExt,
|
||||
MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt,
|
||||
};
|
||||
|
||||
use zed_actions::OpenRemote;
|
||||
|
|
@ -733,23 +733,18 @@ impl TitleBar {
|
|||
.map(|w| w.read(cx).focus_handle(cx))
|
||||
.unwrap_or_else(|| cx.focus_handle());
|
||||
|
||||
let sibling_workspace_ids: HashSet<WorkspaceId> = self
|
||||
let window_project_groups: Vec<_> = self
|
||||
.multi_workspace
|
||||
.as_ref()
|
||||
.and_then(|mw| mw.upgrade())
|
||||
.map(|mw| {
|
||||
mw.read(cx)
|
||||
.workspaces()
|
||||
.filter_map(|ws| ws.read(cx).database_id())
|
||||
.collect()
|
||||
})
|
||||
.map(|mw| mw.read(cx).project_group_keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
PopoverMenu::new("recent-projects-menu")
|
||||
.menu(move |window, cx| {
|
||||
Some(recent_projects::RecentProjects::popover(
|
||||
workspace.clone(),
|
||||
sibling_workspace_ids.clone(),
|
||||
window_project_groups.clone(),
|
||||
false,
|
||||
focus_handle.clone(),
|
||||
window,
|
||||
|
|
@ -795,23 +790,18 @@ impl TitleBar {
|
|||
.map(|w| w.read(cx).focus_handle(cx))
|
||||
.unwrap_or_else(|| cx.focus_handle());
|
||||
|
||||
let sibling_workspace_ids: HashSet<WorkspaceId> = self
|
||||
let window_project_groups: Vec<_> = self
|
||||
.multi_workspace
|
||||
.as_ref()
|
||||
.and_then(|mw| mw.upgrade())
|
||||
.map(|mw| {
|
||||
mw.read(cx)
|
||||
.workspaces()
|
||||
.filter_map(|ws| ws.read(cx).database_id())
|
||||
.collect()
|
||||
})
|
||||
.map(|mw| mw.read(cx).project_group_keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
PopoverMenu::new("sidebar-title-recent-projects-menu")
|
||||
.menu(move |window, cx| {
|
||||
Some(recent_projects::RecentProjects::popover(
|
||||
workspace.clone(),
|
||||
sibling_workspace_ids.clone(),
|
||||
window_project_groups.clone(),
|
||||
false,
|
||||
focus_handle.clone(),
|
||||
window,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ pub use settings::SidebarSide;
|
|||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use ui::prelude::*;
|
||||
use util::ResultExt;
|
||||
use util::path_list::PathList;
|
||||
|
|
@ -23,7 +22,6 @@ use ui::{ContextMenu, right_click_menu};
|
|||
|
||||
const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
|
||||
|
||||
use crate::AppState;
|
||||
use crate::{
|
||||
CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, OpenMode,
|
||||
Panel, Workspace, WorkspaceId, client_side_decorations,
|
||||
|
|
@ -53,8 +51,6 @@ actions!(
|
|||
ShowFewerThreads,
|
||||
/// Creates a new thread in the current workspace.
|
||||
NewThread,
|
||||
/// Moves the current workspace's project group to a new window.
|
||||
MoveWorkspaceToNewWindow,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -134,9 +130,6 @@ pub trait Sidebar: Focusable + Render + EventEmitter<SidebarEvent> + Sized {
|
|||
/// Activates the next or previous thread in sidebar order.
|
||||
fn cycle_thread(&mut self, _forward: bool, _window: &mut Window, _cx: &mut Context<Self>) {}
|
||||
|
||||
/// Moves the active workspace's project group to a new window.
|
||||
fn move_workspace_to_new_window(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
|
||||
|
||||
/// Return an opaque JSON blob of sidebar-specific state to persist.
|
||||
fn serialized_state(&self, _cx: &App) -> Option<String> {
|
||||
None
|
||||
|
|
@ -164,7 +157,6 @@ pub trait SidebarHandle: 'static + Send + Sync {
|
|||
fn toggle_thread_switcher(&self, select_last: bool, window: &mut Window, cx: &mut App);
|
||||
fn cycle_project(&self, forward: bool, window: &mut Window, cx: &mut App);
|
||||
fn cycle_thread(&self, forward: bool, window: &mut Window, cx: &mut App);
|
||||
fn move_workspace_to_new_window(&self, window: &mut Window, cx: &mut App);
|
||||
|
||||
fn is_threads_list_view_active(&self, cx: &App) -> bool;
|
||||
|
||||
|
|
@ -243,15 +235,6 @@ impl<T: Sidebar> SidebarHandle for Entity<T> {
|
|||
});
|
||||
}
|
||||
|
||||
fn move_workspace_to_new_window(&self, window: &mut Window, cx: &mut App) {
|
||||
let entity = self.clone();
|
||||
window.defer(cx, move |window, cx| {
|
||||
entity.update(cx, |this, cx| {
|
||||
this.move_workspace_to_new_window(window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn is_threads_list_view_active(&self, cx: &App) -> bool {
|
||||
self.read(cx).is_threads_list_view_active()
|
||||
}
|
||||
|
|
@ -285,13 +268,6 @@ enum ActiveWorkspace {
|
|||
}
|
||||
|
||||
impl ActiveWorkspace {
|
||||
fn persistent_index(&self) -> Option<usize> {
|
||||
match self {
|
||||
Self::Persistent(index) => Some(*index),
|
||||
Self::Transient(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn transient_workspace(&self) -> Option<&Entity<Workspace>> {
|
||||
match self {
|
||||
Self::Transient(workspace) => Some(workspace),
|
||||
|
|
@ -595,7 +571,8 @@ impl MultiWorkspace {
|
|||
if self.project_group_keys.contains(&project_group_key) {
|
||||
return;
|
||||
}
|
||||
self.project_group_keys.push(project_group_key);
|
||||
// Store newest first so the vec is in "most recently added"
|
||||
self.project_group_keys.insert(0, project_group_key);
|
||||
}
|
||||
|
||||
pub fn restore_project_group_keys(&mut self, keys: Vec<ProjectGroupKey>) {
|
||||
|
|
@ -625,7 +602,6 @@ impl MultiWorkspace {
|
|||
let mut groups = self
|
||||
.project_group_keys
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|key| (key.clone(), Vec::new()))
|
||||
.collect::<Vec<_>>();
|
||||
for workspace in &self.workspaces {
|
||||
|
|
@ -750,19 +726,67 @@ impl MultiWorkspace {
|
|||
key: &ProjectGroupKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.project_group_keys.retain(|k| k != key);
|
||||
|
||||
) -> Task<Result<bool>> {
|
||||
let workspaces: Vec<_> = self
|
||||
.workspaces_for_project_group(key, cx)
|
||||
.cloned()
|
||||
.collect();
|
||||
for workspace in workspaces {
|
||||
self.remove(&workspace, window, cx);
|
||||
}
|
||||
|
||||
self.serialize(cx);
|
||||
cx.notify();
|
||||
// Compute the neighbor while the key is still in the list.
|
||||
let neighbor_key = {
|
||||
let pos = self.project_group_keys.iter().position(|k| k == key);
|
||||
pos.and_then(|pos| {
|
||||
// Keys are in display order, so pos+1 is below
|
||||
// and pos-1 is above. Try below first.
|
||||
self.project_group_keys.get(pos + 1).or_else(|| {
|
||||
pos.checked_sub(1)
|
||||
.and_then(|i| self.project_group_keys.get(i))
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
};
|
||||
|
||||
// Now remove the key.
|
||||
self.project_group_keys.retain(|k| k != key);
|
||||
|
||||
self.remove(
|
||||
workspaces,
|
||||
move |this, window, cx| {
|
||||
if let Some(neighbor_key) = neighbor_key {
|
||||
return this.find_or_create_local_workspace(
|
||||
neighbor_key.path_list().clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
// No other project groups remain — create an empty workspace.
|
||||
let app_state = this.workspace().read(cx).app_state().clone();
|
||||
let project = Project::local(
|
||||
app_state.client.clone(),
|
||||
app_state.node_runtime.clone(),
|
||||
app_state.user_store.clone(),
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
None,
|
||||
project::LocalProjectFlags::default(),
|
||||
cx,
|
||||
);
|
||||
let new_workspace =
|
||||
cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
|
||||
Task::ready(Ok(new_workspace))
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Finds an existing workspace whose root paths exactly match the given path list.
|
||||
pub fn workspace_for_paths(&self, path_list: &PathList, cx: &App) -> Option<Entity<Workspace>> {
|
||||
self.workspaces
|
||||
.iter()
|
||||
.find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == *path_list)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Finds an existing workspace in this multi-workspace whose paths match,
|
||||
|
|
@ -775,12 +799,7 @@ impl MultiWorkspace {
|
|||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Entity<Workspace>>> {
|
||||
if let Some(workspace) = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.find(|ws| PathList::new(&ws.read(cx).root_paths(cx)) == path_list)
|
||||
.cloned()
|
||||
{
|
||||
if let Some(workspace) = self.workspace_for_paths(&path_list, cx) {
|
||||
self.activate(workspace.clone(), window, cx);
|
||||
return Task::ready(Ok(workspace));
|
||||
}
|
||||
|
|
@ -1201,174 +1220,116 @@ impl MultiWorkspace {
|
|||
})
|
||||
}
|
||||
|
||||
/// Removes one or more workspaces from this multi-workspace.
|
||||
///
|
||||
/// If the active workspace is among those being removed,
|
||||
/// `fallback_workspace` is called **synchronously before the removal
|
||||
/// begins** to produce a `Task` that resolves to the workspace that
|
||||
/// should become active. The fallback must not be one of the
|
||||
/// workspaces being removed.
|
||||
///
|
||||
/// Returns `true` if any workspaces were actually removed.
|
||||
pub fn remove(
|
||||
&mut self,
|
||||
workspace: &Entity<Workspace>,
|
||||
workspaces: impl IntoIterator<Item = Entity<Workspace>>,
|
||||
fallback_workspace: impl FnOnce(
|
||||
&mut Self,
|
||||
&mut Window,
|
||||
&mut Context<Self>,
|
||||
) -> Task<Result<Entity<Workspace>>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
let Some(index) = self.workspaces.iter().position(|w| w == workspace) else {
|
||||
return false;
|
||||
};
|
||||
) -> Task<Result<bool>> {
|
||||
let workspaces: Vec<_> = workspaces.into_iter().collect();
|
||||
|
||||
let old_key = workspace.read(cx).project_group_key(cx);
|
||||
if workspaces.is_empty() {
|
||||
return Task::ready(Ok(false));
|
||||
}
|
||||
|
||||
if self.workspaces.len() <= 1 {
|
||||
let has_worktrees = workspace.read(cx).visible_worktrees(cx).next().is_some();
|
||||
let removing_active = workspaces.iter().any(|ws| ws == self.workspace());
|
||||
let original_active = self.workspace().clone();
|
||||
|
||||
if !has_worktrees {
|
||||
return false;
|
||||
}
|
||||
let fallback_task = removing_active.then(|| fallback_workspace(self, window, cx));
|
||||
|
||||
let old_workspace = workspace.clone();
|
||||
let old_entity_id = old_workspace.entity_id();
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
// Prompt each workspace for unsaved changes. If any workspace
|
||||
// has dirty buffers, save_all_internal will emit Activate to
|
||||
// bring it into view before showing the save dialog.
|
||||
for workspace in &workspaces {
|
||||
let should_continue = workspace
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
workspace.save_all_internal(crate::SaveIntent::Close, window, cx)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
let app_state = old_workspace.read(cx).app_state().clone();
|
||||
|
||||
let project = Project::local(
|
||||
app_state.client.clone(),
|
||||
app_state.node_runtime.clone(),
|
||||
app_state.user_store.clone(),
|
||||
app_state.languages.clone(),
|
||||
app_state.fs.clone(),
|
||||
None,
|
||||
project::LocalProjectFlags::default(),
|
||||
cx,
|
||||
);
|
||||
|
||||
let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
|
||||
|
||||
self.workspaces[0] = new_workspace.clone();
|
||||
self.active_workspace = ActiveWorkspace::Persistent(0);
|
||||
|
||||
Self::subscribe_to_workspace(&new_workspace, window, cx);
|
||||
|
||||
self.sync_sidebar_to_workspace(&new_workspace, cx);
|
||||
|
||||
let weak_self = cx.weak_entity();
|
||||
|
||||
new_workspace.update(cx, |workspace, cx| {
|
||||
workspace.set_multi_workspace(weak_self, cx);
|
||||
});
|
||||
|
||||
self.detach_workspace(&old_workspace, cx);
|
||||
|
||||
cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old_entity_id));
|
||||
cx.emit(MultiWorkspaceEvent::WorkspaceAdded(new_workspace));
|
||||
cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
|
||||
} else {
|
||||
let removed_workspace = self.workspaces.remove(index);
|
||||
|
||||
if let Some(active_index) = self.active_workspace.persistent_index() {
|
||||
if active_index >= self.workspaces.len() {
|
||||
self.active_workspace = ActiveWorkspace::Persistent(self.workspaces.len() - 1);
|
||||
} else if active_index > index {
|
||||
self.active_workspace = ActiveWorkspace::Persistent(active_index - 1);
|
||||
if !should_continue {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
self.detach_workspace(&removed_workspace, cx);
|
||||
// If we're removing the active workspace, await the
|
||||
// fallback and switch to it before tearing anything down.
|
||||
// Otherwise restore the original active workspace in case
|
||||
// prompting switched away from it.
|
||||
if let Some(fallback_task) = fallback_task {
|
||||
let new_active = fallback_task.await?;
|
||||
|
||||
cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(
|
||||
removed_workspace.entity_id(),
|
||||
));
|
||||
cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
|
||||
}
|
||||
|
||||
let key_still_in_use = self
|
||||
.workspaces
|
||||
.iter()
|
||||
.any(|ws| ws.read(cx).project_group_key(cx) == old_key);
|
||||
|
||||
if !key_still_in_use {
|
||||
self.project_group_keys.retain(|k| k != &old_key);
|
||||
}
|
||||
|
||||
self.serialize(cx);
|
||||
self.focus_active_workspace(window, cx);
|
||||
cx.notify();
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn move_workspace_to_new_window(
|
||||
&mut self,
|
||||
workspace: &Entity<Workspace>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let workspace = workspace.clone();
|
||||
if !self.remove(&workspace, window, cx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let app_state: Arc<AppState> = workspace.read(cx).app_state().clone();
|
||||
|
||||
cx.defer(move |cx| {
|
||||
let options = (app_state.build_window_options)(None, cx);
|
||||
|
||||
let Ok(window) = cx.open_window(options, |window, cx| {
|
||||
cx.new(|cx| MultiWorkspace::new(workspace, window, cx))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let _ = window.update(cx, |_, window, _| {
|
||||
window.activate_window();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn move_project_group_to_new_window(
|
||||
&mut self,
|
||||
key: &ProjectGroupKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let workspaces: Vec<_> = self
|
||||
.workspaces_for_project_group(key, cx)
|
||||
.cloned()
|
||||
.collect();
|
||||
if workspaces.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.project_group_keys.retain(|k| k != key);
|
||||
|
||||
let mut removed = Vec::new();
|
||||
for workspace in &workspaces {
|
||||
if self.remove(workspace, window, cx) {
|
||||
removed.push(workspace.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if removed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let app_state = removed[0].read(cx).app_state().clone();
|
||||
|
||||
cx.defer(move |cx| {
|
||||
let options = (app_state.build_window_options)(None, cx);
|
||||
|
||||
let first = removed[0].clone();
|
||||
let rest = removed[1..].to_vec();
|
||||
|
||||
let Ok(new_window) = cx.open_window(options, |window, cx| {
|
||||
cx.new(|cx| MultiWorkspace::new(first, window, cx))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
new_window
|
||||
.update(cx, |mw, window, cx| {
|
||||
for workspace in rest {
|
||||
mw.activate(workspace, window, cx);
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
assert!(
|
||||
!workspaces.contains(&new_active),
|
||||
"fallback workspace must not be one of the workspaces being removed"
|
||||
);
|
||||
this.activate(new_active, window, cx);
|
||||
})?;
|
||||
} else {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
if *this.workspace() != original_active {
|
||||
this.activate(original_active, window, cx);
|
||||
}
|
||||
window.activate_window();
|
||||
})
|
||||
.log_err();
|
||||
});
|
||||
})?;
|
||||
}
|
||||
|
||||
// Actually remove the workspaces.
|
||||
this.update_in(cx, |this, _, cx| {
|
||||
// Save a handle to the active workspace so we can restore
|
||||
// its index after the removals shift the vec around.
|
||||
let active_workspace = this.workspace().clone();
|
||||
|
||||
let mut removed_workspaces: Vec<Entity<Workspace>> = Vec::new();
|
||||
|
||||
this.workspaces.retain(|ws| {
|
||||
if workspaces.contains(ws) {
|
||||
removed_workspaces.push(ws.clone());
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
for workspace in &removed_workspaces {
|
||||
this.detach_workspace(workspace, cx);
|
||||
cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id()));
|
||||
}
|
||||
|
||||
let removed_any = !removed_workspaces.is_empty();
|
||||
|
||||
if removed_any {
|
||||
// Restore the active workspace index after removals.
|
||||
if let Some(new_index) = this
|
||||
.workspaces
|
||||
.iter()
|
||||
.position(|ws| ws == &active_workspace)
|
||||
{
|
||||
this.active_workspace = ActiveWorkspace::Persistent(new_index);
|
||||
}
|
||||
|
||||
this.serialize(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
Ok(removed_any)
|
||||
})?
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_project(
|
||||
|
|
@ -1528,18 +1489,11 @@ impl Render for MultiWorkspace {
|
|||
sidebar.cycle_thread(true, window, cx);
|
||||
}
|
||||
}))
|
||||
.on_action(
|
||||
cx.listener(|this: &mut Self, _: &PreviousThread, window, cx| {
|
||||
.on_action(cx.listener(
|
||||
|this: &mut Self, _: &PreviousThread, window, cx| {
|
||||
if let Some(sidebar) = &this.sidebar {
|
||||
sidebar.cycle_thread(false, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.on_action(cx.listener(
|
||||
|this: &mut Self, _: &MoveWorkspaceToNewWindow, window, cx| {
|
||||
if let Some(sidebar) = &this.sidebar {
|
||||
sidebar.move_workspace_to_new_window(window, cx);
|
||||
}
|
||||
},
|
||||
))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -147,8 +147,8 @@ async fn test_project_group_keys_add_workspace(cx: &mut TestAppContext) {
|
|||
2,
|
||||
"should have two keys after adding a second workspace"
|
||||
);
|
||||
assert_eq!(*keys[0], key_a);
|
||||
assert_eq!(*keys[1], key_b);
|
||||
assert_eq!(*keys[0], key_b);
|
||||
assert_eq!(*keys[1], key_a);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -228,8 +228,8 @@ async fn test_project_group_keys_on_worktree_added(cx: &mut TestAppContext) {
|
|||
2,
|
||||
"should have both the original and updated key"
|
||||
);
|
||||
assert_eq!(*keys[0], initial_key);
|
||||
assert_eq!(*keys[1], updated_key);
|
||||
assert_eq!(*keys[0], updated_key);
|
||||
assert_eq!(*keys[1], initial_key);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -277,8 +277,8 @@ async fn test_project_group_keys_on_worktree_removed(cx: &mut TestAppContext) {
|
|||
2,
|
||||
"should accumulate both the original and post-removal key"
|
||||
);
|
||||
assert_eq!(*keys[0], initial_key);
|
||||
assert_eq!(*keys[1], updated_key);
|
||||
assert_eq!(*keys[0], updated_key);
|
||||
assert_eq!(*keys[1], initial_key);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -334,8 +334,8 @@ async fn test_project_group_keys_across_multiple_workspaces_and_worktree_changes
|
|||
3,
|
||||
"should have key_a, key_b, and the updated key_a with root_c"
|
||||
);
|
||||
assert_eq!(*keys[0], key_a);
|
||||
assert_eq!(*keys[0], key_a_updated);
|
||||
assert_eq!(*keys[1], key_b);
|
||||
assert_eq!(*keys[2], key_a_updated);
|
||||
assert_eq!(*keys[2], key_a);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2565,10 +2565,11 @@ mod tests {
|
|||
the newly activated workspace's database id"
|
||||
);
|
||||
|
||||
// --- Remove the second workspace (index 1) ---
|
||||
// --- Remove the first workspace (index 0, which is not the active one) ---
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
let ws = mw.workspaces().nth(1).unwrap().clone();
|
||||
mw.remove(&ws, window, cx);
|
||||
let ws = mw.workspaces().nth(0).unwrap().clone();
|
||||
mw.remove([ws], |_, _, _| unreachable!(), window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
|
@ -4209,7 +4210,7 @@ mod tests {
|
|||
workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
|
||||
ws.set_database_id(workspace2_db_id)
|
||||
});
|
||||
mw.activate(workspace.clone(), window, cx);
|
||||
mw.add(workspace.clone(), window, cx);
|
||||
});
|
||||
|
||||
// Save a full workspace row to the DB directly.
|
||||
|
|
@ -4238,7 +4239,8 @@ 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, window, cx);
|
||||
mw.remove([ws], |_, _, _| unreachable!(), window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
|
@ -4306,7 +4308,7 @@ mod tests {
|
|||
workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
|
||||
ws.set_database_id(ws2_id)
|
||||
});
|
||||
mw.activate(workspace.clone(), window, cx);
|
||||
mw.add(workspace.clone(), window, cx);
|
||||
});
|
||||
|
||||
let session_id = "test-zombie-session";
|
||||
|
|
@ -4347,7 +4349,8 @@ 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, window, cx);
|
||||
mw.remove([ws], |_, _, _| unreachable!(), window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
|
||||
cx.run_until_parked();
|
||||
|
|
@ -4404,7 +4407,7 @@ mod tests {
|
|||
workspace.update(cx, |ws: &mut crate::Workspace, _cx| {
|
||||
ws.set_database_id(workspace2_db_id)
|
||||
});
|
||||
mw.activate(workspace.clone(), window, cx);
|
||||
mw.add(workspace.clone(), window, cx);
|
||||
});
|
||||
|
||||
// Save a full workspace row to the DB directly and let it settle.
|
||||
|
|
@ -4429,7 +4432,8 @@ 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, window, cx);
|
||||
mw.remove([ws], |_, _, _| unreachable!(), window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
|
||||
// Simulate the quit handler pattern: collect flush tasks + pending
|
||||
|
|
@ -4849,8 +4853,8 @@ mod tests {
|
|||
.map(Into::into)
|
||||
.collect();
|
||||
let expected_keys = vec![
|
||||
ProjectGroupKey::new(None, PathList::new(&["/other-project"])),
|
||||
ProjectGroupKey::new(None, PathList::new(&["/repo"])),
|
||||
ProjectGroupKey::new(None, PathList::new(&["/other-project"])),
|
||||
];
|
||||
assert_eq!(
|
||||
restored_keys, expected_keys,
|
||||
|
|
@ -4903,4 +4907,111 @@ mod tests {
|
|||
"The restored active workspace should be the linked worktree project"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_remove_project_group_falls_back_to_neighbor(cx: &mut gpui::TestAppContext) {
|
||||
crate::tests::init_test(cx);
|
||||
cx.update(|cx| {
|
||||
cx.set_staff(true);
|
||||
cx.update_flags(true, vec!["agent-v2".to_string()]);
|
||||
});
|
||||
|
||||
let fs = fs::FakeFs::new(cx.executor());
|
||||
let dir_a = unique_test_dir(&fs, "group-a").await;
|
||||
let dir_b = unique_test_dir(&fs, "group-b").await;
|
||||
let dir_c = unique_test_dir(&fs, "group-c").await;
|
||||
|
||||
let project_a = Project::test(fs.clone(), [dir_a.as_path()], cx).await;
|
||||
let project_b = Project::test(fs.clone(), [dir_b.as_path()], cx).await;
|
||||
let project_c = Project::test(fs.clone(), [dir_c.as_path()], cx).await;
|
||||
|
||||
// Create a multi-workspace with project A, then add B and C.
|
||||
// project_group_keys stores newest first: [C, B, A].
|
||||
// Sidebar displays in the same order: C (top), B (middle), A (bottom).
|
||||
let (multi_workspace, cx) = cx
|
||||
.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
|
||||
|
||||
multi_workspace.update(cx, |mw, cx| mw.open_sidebar(cx));
|
||||
|
||||
let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(project_b.clone(), window, cx)
|
||||
});
|
||||
let _workspace_c = multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(project_c.clone(), window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
|
||||
let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
|
||||
let key_c = project_c.read_with(cx, |p, cx| p.project_group_key(cx));
|
||||
|
||||
// Activate workspace B so removing its group exercises the fallback.
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.activate(workspace_b.clone(), window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// --- Remove group B (the middle one). ---
|
||||
// In the sidebar [C, B, A], "below" B is A.
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.remove_project_group(&key_b, window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let active_paths =
|
||||
multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
|
||||
assert_eq!(
|
||||
active_paths
|
||||
.iter()
|
||||
.map(|p| p.to_path_buf())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![dir_a.clone()],
|
||||
"After removing the middle group, should fall back to the group below (A)"
|
||||
);
|
||||
|
||||
// After removing B, keys = [A, C], sidebar = [C, A].
|
||||
// Activate workspace A (the bottom) so removing it tests the
|
||||
// "fall back upward" path.
|
||||
let workspace_a =
|
||||
multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().cloned().unwrap());
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.activate(workspace_a.clone(), window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// --- Remove group A (the bottom one in sidebar). ---
|
||||
// Nothing below A, so should fall back upward to C.
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.remove_project_group(&key_a, window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let active_paths =
|
||||
multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
|
||||
assert_eq!(
|
||||
active_paths
|
||||
.iter()
|
||||
.map(|p| p.to_path_buf())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![dir_c.clone()],
|
||||
"After removing the bottom group, should fall back to the group above (C)"
|
||||
);
|
||||
|
||||
// --- Remove group C (the only one remaining). ---
|
||||
// Should create an empty workspace.
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.remove_project_group(&key_c, window, cx)
|
||||
.detach_and_log_err(cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let active_paths =
|
||||
multi_workspace.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx));
|
||||
assert!(
|
||||
active_paths.is_empty(),
|
||||
"After removing the only remaining group, should have an empty workspace"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,10 +31,10 @@ mod workspace_settings;
|
|||
pub use crate::notifications::NotificationFrame;
|
||||
pub use dock::Panel;
|
||||
pub use multi_workspace::{
|
||||
CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MoveWorkspaceToNewWindow,
|
||||
MultiWorkspace, MultiWorkspaceEvent, NewThread, NextProject, NextThread, PreviousProject,
|
||||
PreviousThread, ShowFewerThreads, ShowMoreThreads, Sidebar, SidebarEvent, SidebarHandle,
|
||||
SidebarRenderState, SidebarSide, ToggleWorkspaceSidebar, sidebar_side_context_menu,
|
||||
CloseWorkspaceSidebar, DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace,
|
||||
MultiWorkspaceEvent, NewThread, NextProject, NextThread, PreviousProject, PreviousThread,
|
||||
ShowFewerThreads, ShowMoreThreads, Sidebar, SidebarEvent, SidebarHandle, SidebarRenderState,
|
||||
SidebarSide, ToggleWorkspaceSidebar, sidebar_side_context_menu,
|
||||
};
|
||||
pub use path_list::{PathList, SerializedPathList};
|
||||
pub use toast_layer::{ToastAction, ToastLayer, ToastView};
|
||||
|
|
@ -3374,6 +3374,7 @@ impl Workspace {
|
|||
|
||||
if remaining_dirty_items.len() > 1 {
|
||||
let answer = workspace.update_in(cx, |_, window, cx| {
|
||||
cx.emit(Event::Activate);
|
||||
let detail = Pane::file_names_for_prompt(
|
||||
&mut remaining_dirty_items.iter().map(|(_, handle)| handle),
|
||||
cx,
|
||||
|
|
@ -10937,6 +10938,113 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_remove_workspace_prompts_for_unsaved_changes(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/root", json!({ "one": "" })).await;
|
||||
|
||||
let project_a = Project::test(fs.clone(), ["root".as_ref()], cx).await;
|
||||
let project_b = Project::test(fs.clone(), ["root".as_ref()], cx).await;
|
||||
let multi_workspace_handle =
|
||||
cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
|
||||
cx.run_until_parked();
|
||||
|
||||
multi_workspace_handle
|
||||
.update(cx, |mw, _window, cx| mw.open_sidebar(cx))
|
||||
.unwrap();
|
||||
|
||||
let workspace_a = multi_workspace_handle
|
||||
.read_with(cx, |mw, _| mw.workspace().clone())
|
||||
.unwrap();
|
||||
|
||||
let workspace_b = multi_workspace_handle
|
||||
.update(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(project_b, window, cx)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Activate workspace A.
|
||||
multi_workspace_handle
|
||||
.update(cx, |mw, window, cx| {
|
||||
mw.activate(workspace_a.clone(), window, cx);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
|
||||
|
||||
// Workspace B has a dirty item.
|
||||
let item_b = cx.new(|cx| TestItem::new(cx).with_dirty(true));
|
||||
workspace_b.update_in(cx, |w, window, cx| {
|
||||
w.add_item_to_active_pane(Box::new(item_b.clone()), None, true, window, cx)
|
||||
});
|
||||
|
||||
// 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)
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
// The prompt should have activated workspace B.
|
||||
multi_workspace_handle
|
||||
.read_with(cx, |mw, _| {
|
||||
assert_eq!(
|
||||
mw.workspace(),
|
||||
&workspace_b,
|
||||
"workspace B should be active while prompting"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Cancel the prompt — user stays on workspace B.
|
||||
cx.simulate_prompt_answer("Cancel");
|
||||
cx.run_until_parked();
|
||||
let removed = remove_task.await.unwrap();
|
||||
assert!(!removed, "removal should have been cancelled");
|
||||
|
||||
multi_workspace_handle
|
||||
.read_with(cx, |mw, _| {
|
||||
assert_eq!(
|
||||
mw.workspace(),
|
||||
&workspace_b,
|
||||
"user should stay on workspace B after cancelling"
|
||||
);
|
||||
assert_eq!(mw.workspaces().count(), 2, "both workspaces should remain");
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Try again. This time accept the prompt.
|
||||
let remove_task = multi_workspace_handle
|
||||
.update(cx, |mw, window, cx| {
|
||||
// First switch back to A.
|
||||
mw.activate(workspace_a.clone(), window, cx);
|
||||
mw.remove([workspace_b.clone()], |_, _, _| unreachable!(), window, cx)
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Accept the save prompt.
|
||||
cx.simulate_prompt_answer("Don't Save");
|
||||
cx.run_until_parked();
|
||||
let removed = remove_task.await.unwrap();
|
||||
assert!(removed, "removal should have succeeded");
|
||||
|
||||
// Should be back on workspace A, and B should be gone.
|
||||
multi_workspace_handle
|
||||
.read_with(cx, |mw, _| {
|
||||
assert_eq!(
|
||||
mw.workspace(),
|
||||
&workspace_a,
|
||||
"should be back on workspace A after removing B"
|
||||
);
|
||||
assert_eq!(mw.workspaces().count(), 1, "only workspace A should remain");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_close_window_with_serializable_items(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
|
|
|||
|
|
@ -6172,8 +6172,8 @@ mod tests {
|
|||
assert_eq!(
|
||||
mw.project_group_keys().cloned().collect::<Vec<_>>(),
|
||||
vec![
|
||||
ProjectGroupKey::new(None, PathList::new(&[dir1])),
|
||||
ProjectGroupKey::new(None, PathList::new(&[dir2])),
|
||||
ProjectGroupKey::new(None, PathList::new(&[dir1])),
|
||||
]
|
||||
);
|
||||
assert_eq!(mw.workspaces().count(), 1);
|
||||
|
|
|
|||
Loading…
Reference in a new issue