Detailed changes
@@ -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());
+
+ // 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,
+ })
+ });
- // 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
- .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)
- });
+ // 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;
+ }
- // 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 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 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 multi_workspace = self.multi_workspace.upgrade()?;
+ let workspace = multi_workspace
+ .read(cx)
+ .workspace_for_paths(folder_paths, cx)?;
- let above = self.contents.entries[group_start..pos]
- .iter()
- .rev()
- .find_map(|entry| {
- if let ListEntry::Thread(t) = entry {
- Some(t)
- } else {
- None
- }
- });
+ // 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)
+ });
- 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(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,
+ )
});
- 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,
- );
- });
- }
+ 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,
+ );
+ })?;
}
- } 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);
- });
+ 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));
+
+ 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);
+ });
+ }
}
}
}
+ 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;
- };
-
- let old_key = workspace.read(cx).project_group_key(cx);
-
- if self.workspaces.len() <= 1 {
- let has_worktrees = workspace.read(cx).visible_worktrees(cx).next().is_some();
-
- if !has_worktrees {
- return false;
- }
-
- let old_workspace = workspace.clone();
- let old_entity_id = old_workspace.entity_id();
-
- 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);
+ ) -> Task<Result<bool>> {
+ let workspaces: Vec<_> = workspaces.into_iter().collect();
- Self::subscribe_to_workspace(&new_workspace, window, cx);
-
- self.sync_sidebar_to_workspace(&new_workspace, cx);
+ if workspaces.is_empty() {
+ return Task::ready(Ok(false));
+ }
- let weak_self = cx.weak_entity();
+ let removing_active = workspaces.iter().any(|ws| ws == self.workspace());
+ let original_active = self.workspace().clone();
- new_workspace.update(cx, |workspace, cx| {
- workspace.set_multi_workspace(weak_self, cx);
- });
+ let fallback_task = removing_active.then(|| fallback_workspace(self, window, 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);
+ 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?;
- 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);
-
- 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 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?;
+
+ 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);
+ }
+ })?;
}
- }
- if removed.is_empty() {
- return;
- }
+ // 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 app_state = removed[0].read(cx).app_state().clone();
+ let mut removed_workspaces: Vec<Entity<Workspace>> = Vec::new();
- cx.defer(move |cx| {
- let options = (app_state.build_window_options)(None, cx);
+ this.workspaces.retain(|ws| {
+ if workspaces.contains(ws) {
+ removed_workspaces.push(ws.clone());
+ false
+ } else {
+ true
+ }
+ });
- let first = removed[0].clone();
- let rest = removed[1..].to_vec();
+ for workspace in &removed_workspaces {
+ this.detach_workspace(workspace, cx);
+ cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(workspace.entity_id()));
+ }
- let Ok(new_window) = cx.open_window(options, |window, cx| {
- cx.new(|cx| MultiWorkspace::new(first, window, cx))
- }) else {
- return;
- };
+ let removed_any = !removed_workspaces.is_empty();
- new_window
- .update(cx, |mw, window, cx| {
- for workspace in rest {
- mw.activate(workspace, window, cx);
+ 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);
}
- window.activate_window();
- })
- .log_err();
- });
+
+ this.serialize(cx);
+ cx.notify();
+ }
+
+ Ok(removed_any)
+ })?
+ })
}
pub fn open_project(
@@ -1528,17 +1489,10 @@ impl Render for MultiWorkspace {
sidebar.cycle_thread(true, 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| {
+ |this: &mut Self, _: &PreviousThread, window, cx| {
if let Some(sidebar) = &this.sidebar {
- sidebar.move_workspace_to_new_window(window, cx);
+ sidebar.cycle_thread(false, 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);