Detailed changes
@@ -820,38 +820,6 @@ impl ThreadMetadataStore {
self.mutate_thread_paths(&thread_ids, mutate, cx);
}
- /// Like `change_worktree_paths`, but looks up threads by their
- /// `main_worktree_paths` instead of `folder_paths`. Used when
- /// migrating threads for project group key changes where the
- /// lookup key is the group key's main paths.
- /// When `remote_connection` is provided, only threads with a matching
- /// remote connection are affected.
- pub fn change_worktree_paths_by_main(
- &mut self,
- current_main_paths: &PathList,
- remote_connection: Option<&RemoteConnectionOptions>,
- mutate: impl Fn(&mut WorktreePaths),
- cx: &mut Context<Self>,
- ) {
- let thread_ids: Vec<_> = self
- .threads_by_main_paths
- .get(current_main_paths)
- .into_iter()
- .flatten()
- .filter(|id| {
- same_remote_connection_identity(
- self.threads
- .get(id)
- .and_then(|t| t.remote_connection.as_ref()),
- remote_connection,
- )
- })
- .copied()
- .collect();
-
- self.mutate_thread_paths(&thread_ids, mutate, cx);
- }
-
fn mutate_thread_paths(
&mut self,
thread_ids: &[ThreadId],
@@ -1639,7 +1639,7 @@ impl PickerDelegate for RecentProjectsDelegate {
let open_action = workspace::Open {
create_new_window: self.create_new_window,
};
- Button::new("open_local_folder", "Open Local Project")
+ Button::new("open_local_folder", "Open Local Folders")
.key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
.on_click({
let workspace = self.workspace.clone();
@@ -1655,7 +1655,7 @@ impl PickerDelegate for RecentProjectsDelegate {
})
})
.child(
- Button::new("open_remote_folder", "Open Remote Project")
+ Button::new("open_remote_folder", "Open Remote Folder")
.key_binding(KeyBinding::for_action(
&OpenRemote {
from_existing_connection: false,
@@ -1827,7 +1827,7 @@ impl PickerDelegate for RecentProjectsDelegate {
.separator()
})
.entry(
- "Open Local Project",
+ "Open Local Folders",
Some(open_action.boxed_clone()),
{
let workspace_handle = workspace_handle.clone();
@@ -1842,7 +1842,7 @@ impl PickerDelegate for RecentProjectsDelegate {
},
)
.action(
- "Open Remote Project",
+ "Open Remote Folder",
OpenRemote {
from_existing_connection: false,
create_new_window: false,
@@ -426,7 +426,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
create_new_window: false,
};
- Button::new("open_local_folder", "Add Local Project")
+ Button::new("open_local_folder", "Add Local Folders")
.key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
.on_click(cx.listener(move |_, _, window, cx| {
window.dispatch_action(open_action.boxed_clone(), cx);
@@ -434,7 +434,7 @@ impl PickerDelegate for SidebarRecentProjectsDelegate {
}))
})
.child(
- Button::new("open_remote_folder", "Add Remote Project")
+ Button::new("open_remote_folder", "Add Remote Folder")
.key_binding(KeyBinding::for_action(
&OpenRemote {
from_existing_connection: false,
@@ -45,8 +45,8 @@ use ui::{
use util::ResultExt as _;
use util::path_list::PathList;
use workspace::{
- AddFolderToProject, CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent,
- NextProject, NextThread, Open, PreviousProject, PreviousThread, ProjectGroupKey, SaveIntent,
+ CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, NextProject,
+ NextThread, Open, PreviousProject, PreviousThread, ProjectGroupKey, SaveIntent,
ShowFewerThreads, ShowMoreThreads, Sidebar as WorkspaceSidebar, SidebarSide, Toast,
ToggleWorkspaceSidebar, Workspace, notifications::NotificationId, sidebar_side_context_menu,
};
@@ -473,6 +473,7 @@ impl Sidebar {
|this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
MultiWorkspaceEvent::ActiveWorkspaceChanged => {
this.sync_active_entry_from_active_workspace(cx);
+ this.replace_archived_panel_thread(window, cx);
this.observe_draft_editors(cx);
this.update_entries(cx);
}
@@ -483,10 +484,6 @@ impl Sidebar {
MultiWorkspaceEvent::WorkspaceRemoved(_) => {
this.update_entries(cx);
}
- MultiWorkspaceEvent::ProjectGroupKeyUpdated { old_key, new_key } => {
- this.move_threads_for_key_change(old_key, new_key, cx);
- this.update_entries(cx);
- }
},
)
.detach();
@@ -671,51 +668,6 @@ impl Sidebar {
}
}
- fn move_threads_for_key_change(
- &mut self,
- old_key: &ProjectGroupKey,
- new_key: &ProjectGroupKey,
- cx: &mut Context<Self>,
- ) {
- let old_main_paths = old_key.path_list();
- let new_main_paths = new_key.path_list();
-
- let added_paths: Vec<PathBuf> = new_main_paths
- .paths()
- .iter()
- .filter(|p| !old_main_paths.paths().contains(p))
- .cloned()
- .collect();
-
- let removed_paths: Vec<PathBuf> = old_main_paths
- .paths()
- .iter()
- .filter(|p| !new_main_paths.paths().contains(p))
- .cloned()
- .collect();
-
- if added_paths.is_empty() && removed_paths.is_empty() {
- return;
- }
-
- let remote_connection = old_key.host();
- ThreadMetadataStore::global(cx).update(cx, |store, store_cx| {
- store.change_worktree_paths_by_main(
- old_main_paths,
- remote_connection.as_ref(),
- |paths| {
- for path in &added_paths {
- paths.add_path(path, path);
- }
- for path in &removed_paths {
- paths.remove_main_path(path);
- }
- },
- store_cx,
- );
- });
- }
-
fn move_thread_paths(
&mut self,
project: &Entity<project::Project>,
@@ -802,6 +754,29 @@ impl Sidebar {
}
}
+ /// When switching workspaces, the active panel may still be showing
+ /// a thread that was archived from a different workspace. In that
+ /// case, create a fresh draft so the panel has valid content and
+ /// `active_entry` can point at it.
+ fn replace_archived_panel_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let Some(workspace) = self.active_workspace(cx) else {
+ return;
+ };
+ let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) else {
+ return;
+ };
+ let Some(thread_id) = panel.read(cx).active_thread_id(cx) else {
+ return;
+ };
+ let is_archived = ThreadMetadataStore::global(cx)
+ .read(cx)
+ .entry(thread_id)
+ .is_some_and(|m| m.archived);
+ if is_archived {
+ self.create_new_thread(&workspace, window, cx);
+ }
+ }
+
/// Syncs `active_entry` from the agent panel's current state.
/// Called from `ActiveViewChanged` — the panel has settled into its
/// new view, so we can safely read it without race conditions.
@@ -846,14 +821,20 @@ impl Sidebar {
}
if let Some(thread_id) = panel.active_thread_id(cx) {
- let session_id = panel
- .active_agent_thread(cx)
- .map(|thread| thread.read(cx).session_id().clone());
- self.active_entry = Some(ActiveEntry {
- thread_id,
- session_id,
- workspace: active_workspace,
- });
+ let is_archived = ThreadMetadataStore::global(cx)
+ .read(cx)
+ .entry(thread_id)
+ .is_some_and(|m| m.archived);
+ if !is_archived {
+ let session_id = panel
+ .active_agent_thread(cx)
+ .map(|thread| thread.read(cx).session_id().clone());
+ self.active_entry = Some(ActiveEntry {
+ thread_id,
+ session_id,
+ workspace: active_workspace,
+ });
+ }
}
false
@@ -1860,11 +1841,39 @@ impl Sidebar {
id_prefix: &str,
project_group_key: &ProjectGroupKey,
cx: &mut Context<Self>,
- ) -> impl IntoElement {
+ ) -> AnyElement {
let multi_workspace = self.multi_workspace.clone();
- let this = cx.weak_entity();
let project_group_key = project_group_key.clone();
+ let show_menu = multi_workspace
+ .read_with(cx, |mw, _| {
+ project_group_key.host().is_none() && mw.project_group_keys().len() >= 2
+ })
+ .unwrap_or(false);
+
+ if !show_menu {
+ return IconButton::new(
+ SharedString::from(format!("{id_prefix}-close-project-{ix}")),
+ IconName::Close,
+ )
+ .icon_size(IconSize::Small)
+ .tooltip(Tooltip::text("Remove Project"))
+ .on_click(cx.listener({
+ move |_, _, window, cx| {
+ multi_workspace
+ .update(cx, |multi_workspace, cx| {
+ multi_workspace
+ .remove_project_group(&project_group_key, window, cx)
+ .detach_and_log_err(cx);
+ })
+ .ok();
+ }
+ }))
+ .into_any_element();
+ }
+
+ let this = cx.weak_entity();
+
PopoverMenu::new(format!("{id_prefix}project-header-menu-{ix}"))
.trigger(
IconButton::new(
@@ -1889,108 +1898,44 @@ impl Sidebar {
let multi_workspace = multi_workspace.clone();
let project_group_key = project_group_key.clone();
- let has_multiple_projects = multi_workspace
- .read_with(cx, |mw, _| mw.project_group_keys().len() >= 2)
- .unwrap_or(false);
-
let menu =
ContextMenu::build_persistent(window, cx, move |menu, _window, menu_cx| {
let weak_menu = menu_cx.weak_entity();
- let mut menu = menu
- .header("Project Folders")
- .end_slot_action(Box::new(menu::EndSlot));
- for path in project_group_key.path_list().paths() {
- let Some(name) = path.file_name() else {
- continue;
- };
- let name: SharedString = name.to_string_lossy().into_owned().into();
- let path = path.clone();
- let project_group_key = project_group_key.clone();
- let multi_workspace = multi_workspace.clone();
- let weak_menu = weak_menu.clone();
- menu = menu.entry_with_end_slot_on_hover(
- name.clone(),
- None,
- |_, _| {},
- IconName::Close,
- "Remove Folder".into(),
- move |_window, cx| {
- multi_workspace
- .update(cx, |multi_workspace, cx| {
- multi_workspace.remove_folder_from_project_group(
- &project_group_key,
- &path,
- cx,
- );
- })
- .ok();
- weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
- },
- );
- }
-
- let menu = menu.separator().entry(
- "Add Folder to Project",
- Some(Box::new(AddFolderToProject)),
+ let menu = menu.entry(
+ "Open Project in New Window",
+ Some(Box::new(workspace::MoveProjectToNewWindow)),
{
let project_group_key = project_group_key.clone();
let multi_workspace = multi_workspace.clone();
- let weak_menu = weak_menu.clone();
move |window, cx| {
multi_workspace
.update(cx, |multi_workspace, cx| {
- multi_workspace.prompt_to_add_folders_to_project_group(
- project_group_key.clone(),
- window,
- cx,
- );
+ multi_workspace
+ .open_project_group_in_new_window(
+ &project_group_key,
+ window,
+ cx,
+ )
+ .detach_and_log_err(cx);
})
.ok();
- weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
}
},
);
- let menu = if project_group_key.host().is_none() && has_multiple_projects {
- menu.entry(
- "Open Project in New Window",
- Some(Box::new(workspace::MoveProjectToNewWindow)),
- {
- let project_group_key = project_group_key.clone();
- let multi_workspace = multi_workspace.clone();
- move |window, cx| {
- multi_workspace
- .update(cx, |multi_workspace, cx| {
- multi_workspace
- .open_project_group_in_new_window(
- &project_group_key,
- window,
- cx,
- )
- .detach_and_log_err(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)
- .detach_and_log_err(cx);
- })
- .ok();
- weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
- })
+ menu.entry("Remove Project", None, move |window, cx| {
+ multi_workspace
+ .update(cx, |multi_workspace, cx| {
+ multi_workspace
+ .remove_project_group(&project_group_key, window, cx)
+ .detach_and_log_err(cx);
+ })
+ .ok();
+ weak_menu.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
+ })
});
let this = this.clone();
@@ -2011,6 +1956,7 @@ impl Sidebar {
x: px(0.),
y: px(1.),
})
+ .into_any_element()
}
fn render_sticky_header(
@@ -880,97 +880,6 @@ async fn test_collapse_state_survives_worktree_key_change(cx: &mut TestAppContex
);
}
-#[gpui::test]
-async fn test_adding_folder_to_non_backed_group_migrates_threads(cx: &mut TestAppContext) {
- use workspace::ProjectGroup;
- // When a project group has no backing workspace (e.g. the workspace was
- // closed but the group and its threads remain), adding a folder via
- // `add_folders_to_project_group` should still migrate thread metadata
- // to the new key and cause the sidebar to rerender.
- let (_fs, project) =
- init_multi_project_test(&["/active-project", "/orphan-a", "/orphan-b"], cx).await;
- let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
- let sidebar = setup_sidebar(&multi_workspace, cx);
-
- // Insert a standalone project group for [/orphan-a] with no backing
- // workspace — simulating a group that persisted after its workspace
- // was closed.
- let group_key = ProjectGroupKey::new(None, PathList::new(&[PathBuf::from("/orphan-a")]));
- multi_workspace.update(cx, |mw, _cx| {
- mw.test_add_project_group(ProjectGroup {
- key: group_key.clone(),
- workspaces: Vec::new(),
- expanded: true,
- visible_thread_count: None,
- });
- });
-
- // Verify the group has no backing workspaces.
- multi_workspace.read_with(cx, |mw, cx| {
- let group = mw
- .project_groups(cx)
- .into_iter()
- .find(|g| g.key == group_key)
- .expect("group should exist");
- assert!(
- group.workspaces.is_empty(),
- "group should have no backing workspaces"
- );
- });
-
- // Save threads directly into the metadata store under [/orphan-a].
- save_thread_metadata_with_main_paths(
- "t-1",
- "Thread One",
- PathList::new(&[PathBuf::from("/orphan-a")]),
- PathList::new(&[PathBuf::from("/orphan-a")]),
- chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
- cx,
- );
- save_thread_metadata_with_main_paths(
- "t-2",
- "Thread Two",
- PathList::new(&[PathBuf::from("/orphan-a")]),
- PathList::new(&[PathBuf::from("/orphan-a")]),
- chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 1).unwrap(),
- cx,
- );
- sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
- cx.run_until_parked();
-
- // Verify threads show under the standalone group.
- assert_eq!(
- visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [active-project]",
- "v [orphan-a]",
- " Thread Two",
- " Thread One",
- ]
- );
-
- // Add /orphan-b to the non-backed group.
- multi_workspace.update(cx, |mw, cx| {
- mw.add_folders_to_project_group(&group_key, vec![PathBuf::from("/orphan-b")], cx);
- });
- cx.run_until_parked();
-
- sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
- cx.run_until_parked();
-
- // Threads should now appear under the combined key.
- assert_eq!(
- visible_entries_as_strings(&sidebar, cx),
- vec![
- "v [active-project]",
- "v [orphan-a, orphan-b]",
- " Thread Two",
- " Thread One",
- ]
- );
-}
-
#[gpui::test]
async fn test_visible_entries_as_strings(cx: &mut TestAppContext) {
use workspace::ProjectGroup;
@@ -3017,94 +2926,6 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
);
});
}
-#[gpui::test]
-async fn test_group_level_folder_add_syncs_siblings_but_individual_add_splits(
- cx: &mut TestAppContext,
-) {
- // Group-level operations (via the "..." menu) should keep all workspaces
- // in the group in sync. Individual worktree additions should let a
- // workspace diverge from its group.
- init_test(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;
- fs.insert_tree("/project-c", serde_json::json!({ "src": {} }))
- .await;
- cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
-
- let project_a = project::Project::test(fs.clone(), [Path::new("/project-a")], cx).await;
- let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
- let _sidebar = setup_sidebar(&multi_workspace, cx);
-
- // Add a second workspace in the same group by adding it with the same
- // project so they share a project group key.
- let project_a2 = project::Project::test(fs.clone(), [Path::new("/project-a")], cx).await;
- multi_workspace.update_in(cx, |mw, window, cx| {
- mw.test_add_workspace(project_a2.clone(), window, cx);
- });
- cx.run_until_parked();
-
- // Both workspaces should be in the same group with key [/project-a].
- multi_workspace.read_with(cx, |mw, _cx| {
- assert_eq!(mw.workspaces().count(), 2);
- assert_eq!(mw.project_group_keys().len(), 1);
- });
-
- // --- Group-level add: add /project-b via the group API ---
- let group_key = multi_workspace.read_with(cx, |mw, _cx| mw.project_group_keys()[0].clone());
- multi_workspace.update(cx, |mw, cx| {
- mw.add_folders_to_project_group(&group_key, vec![PathBuf::from("/project-b")], cx);
- });
- cx.run_until_parked();
-
- // Both workspaces should now have /project-b as a worktree.
- multi_workspace.read_with(cx, |mw, cx| {
- for workspace in mw.workspaces() {
- let paths = workspace.read(cx).root_paths(cx);
- assert!(
- paths.iter().any(|p| p.ends_with("project-b")),
- "group-level add should propagate /project-b to all siblings, got {:?}",
- paths,
- );
- }
- });
-
- // --- Individual add: add /project-c directly to one workspace ---
- let first_workspace =
- multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
- let first_project = first_workspace.read_with(cx, |ws, _cx| ws.project().clone());
- first_project
- .update(cx, |project, cx| {
- project.find_or_create_worktree("/project-c", true, cx)
- })
- .await
- .expect("should add worktree");
- cx.run_until_parked();
-
- // The first workspace should now have /project-c but the second should not.
- let second_workspace =
- multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().nth(1).unwrap().clone());
- first_workspace.read_with(cx, |ws, cx| {
- let paths = ws.root_paths(cx);
- assert!(
- paths.iter().any(|p| p.ends_with("project-c")),
- "individual add should give /project-c to this workspace, got {:?}",
- paths,
- );
- });
- second_workspace.read_with(cx, |ws, cx| {
- let paths = ws.root_paths(cx);
- assert!(
- !paths.iter().any(|p| p.ends_with("project-c")),
- "individual add should NOT propagate /project-c to sibling, got {:?}",
- paths,
- );
- });
-}
-
#[gpui::test]
async fn test_draft_title_updates_from_editor_text(cx: &mut TestAppContext) {
let project = init_test_project_with_agent_panel("/my-project", cx).await;
@@ -8654,8 +8475,6 @@ async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &m
});
// Add a second worktree to the project.
- // TODO: Should there be different behavior for calling Project::find_or_create_worktree,
- // or MultiWorkspace::add_folders_to_project_group?
project
.update(cx, |project, cx| {
project.find_or_create_worktree("/project-b", true, cx)
@@ -8738,12 +8557,12 @@ async fn test_non_archive_thread_paths_migrate_on_worktree_add_and_remove(cx: &m
}
#[gpui::test]
-async fn test_worktree_add_only_migrates_threads_for_same_folder_paths(cx: &mut TestAppContext) {
+async fn test_worktree_add_only_regroups_threads_for_changed_workspace(cx: &mut TestAppContext) {
// When two workspaces share the same project group (same main path)
// but have different folder paths (main repo vs linked worktree),
- // adding a worktree to the main workspace should only migrate threads
- // whose folder paths match that workspace — not the linked worktree's
- // threads.
+ // adding a worktree to the main workspace should regroup only that
+ // workspace and its threads into the new project group. Threads for the
+ // linked worktree workspace should remain under the original group.
agent_ui::test_support::init_test(cx);
cx.update(|cx| {
cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
@@ -8787,7 +8606,7 @@ async fn test_worktree_add_only_migrates_threads_for_same_folder_paths(cx: &mut
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 sidebar = setup_sidebar(&multi_workspace, cx);
multi_workspace.update_in(cx, |mw, window, cx| {
mw.test_add_workspace(worktree_project.clone(), window, cx);
});
@@ -8817,7 +8636,8 @@ async fn test_worktree_add_only_migrates_threads_for_same_folder_paths(cx: &mut
let folder_paths_main = PathList::new(&[PathBuf::from("/project")]);
let folder_paths_wt = PathList::new(&[PathBuf::from("/wt-feature")]);
- // Sanity-check: each thread is indexed under its own folder paths.
+ // Sanity-check: each thread is indexed under its own folder paths, but
+ // both appear under the shared sidebar group keyed by the main worktree.
cx.update(|_window, cx| {
let store = ThreadMetadataStore::global(cx).read(cx);
assert_eq!(
@@ -8831,6 +8651,16 @@ async fn test_worktree_add_only_migrates_threads_for_same_folder_paths(cx: &mut
"one thread under [/wt-feature]"
);
});
+ sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
+ cx.run_until_parked();
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ "v [project]",
+ " Worktree Thread {wt-feature}",
+ " Main Thread",
+ ]
+ );
// Add /project-b to the main project only.
main_project
@@ -8841,8 +8671,9 @@ async fn test_worktree_add_only_migrates_threads_for_same_folder_paths(cx: &mut
.expect("should add worktree");
cx.run_until_parked();
- // Main Thread (folder paths [/project]) should have migrated to
- // [/project, /project-b]. Worktree Thread should be unchanged.
+ // Main Thread (folder paths [/project]) should be regrouped to
+ // [/project, /project-b]. Worktree Thread should remain under the
+ // original [/project] group.
let folder_paths_main_b =
PathList::new(&[PathBuf::from("/project"), PathBuf::from("/project-b")]);
cx.update(|_window, cx| {
@@ -8863,6 +8694,18 @@ async fn test_worktree_add_only_migrates_threads_for_same_folder_paths(cx: &mut
"worktree thread should remain unchanged under [/wt-feature]"
);
});
+
+ sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
+ cx.run_until_parked();
+ assert_eq!(
+ visible_entries_as_strings(&sidebar, cx),
+ vec![
+ "v [project]",
+ " Worktree Thread {wt-feature}",
+ "v [project, project-b]",
+ " Main Thread",
+ ]
+ );
}
#[gpui::test]
@@ -1,20 +1,19 @@
use anyhow::Result;
use fs::Fs;
-use gpui::PathPromptOptions;
use gpui::{
AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId,
actions, deferred, px,
};
pub use project::ProjectGroupKey;
-use project::{DirectoryLister, DisableAiSettings, Project};
+use project::{DisableAiSettings, Project};
use release_channel::ReleaseChannel;
use remote::RemoteConnectionOptions;
use settings::Settings;
pub use settings::SidebarSide;
use std::future::Future;
-use std::path::Path;
+
use std::path::PathBuf;
use ui::prelude::*;
use util::ResultExt;
@@ -106,10 +105,6 @@ pub enum MultiWorkspaceEvent {
ActiveWorkspaceChanged,
WorkspaceAdded(Entity<Workspace>),
WorkspaceRemoved(EntityId),
- ProjectGroupKeyUpdated {
- old_key: ProjectGroupKey,
- new_key: ProjectGroupKey,
- },
}
pub enum SidebarEvent {
@@ -584,8 +579,7 @@ impl MultiWorkspace {
return;
}
- // Re-key the group without emitting ProjectGroupKeyUpdated —
- // the Project already emitted WorktreePathsChanged which the
+ // The Project already emitted WorktreePathsChanged which the
// sidebar handles for thread migration.
self.rekey_project_group(old_key, &new_key, cx);
self.serialize(cx);
@@ -685,25 +679,6 @@ impl MultiWorkspace {
}
}
- /// Re-keys a project group and emits `ProjectGroupKeyUpdated` so
- /// the sidebar can migrate thread metadata. Used for direct group
- /// manipulation (add/remove folder) where no Project event fires.
- fn update_project_group_key(
- &mut self,
- old_key: &ProjectGroupKey,
- new_key: &ProjectGroupKey,
- cx: &mut Context<Self>,
- ) {
- self.rekey_project_group(old_key, new_key, cx);
-
- if old_key != new_key && !new_key.path_list().paths().is_empty() {
- cx.emit(MultiWorkspaceEvent::ProjectGroupKeyUpdated {
- old_key: old_key.clone(),
- new_key: new_key.clone(),
- });
- }
- }
-
pub(crate) fn retain_workspace(
&mut self,
workspace: Entity<Workspace>,
@@ -855,126 +830,6 @@ impl MultiWorkspace {
})
}
- pub fn remove_folder_from_project_group(
- &mut self,
- group_key: &ProjectGroupKey,
- path: &Path,
- cx: &mut Context<Self>,
- ) {
- let workspaces = self
- .workspaces_for_project_group(group_key, cx)
- .unwrap_or_default();
-
- let Some(group) = self
- .project_groups
- .iter()
- .find(|group| group.key == *group_key)
- else {
- return;
- };
-
- let new_path_list = group.key.path_list().without_path(path);
- if new_path_list.is_empty() {
- return;
- }
-
- let new_key = ProjectGroupKey::new(group.key.host(), new_path_list);
- self.update_project_group_key(group_key, &new_key, cx);
-
- for workspace in workspaces {
- let project = workspace.read(cx).project().clone();
- project.update(cx, |project, cx| {
- project.remove_worktree_for_main_worktree_path(path, cx);
- });
- }
-
- self.serialize(cx);
- cx.notify();
- }
-
- pub fn prompt_to_add_folders_to_project_group(
- &mut self,
- group_key: ProjectGroupKey,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- let paths = self.workspace().update(cx, |workspace, cx| {
- workspace.prompt_for_open_path(
- PathPromptOptions {
- files: false,
- directories: true,
- multiple: true,
- prompt: None,
- },
- DirectoryLister::Project(workspace.project().clone()),
- window,
- cx,
- )
- });
-
- cx.spawn_in(window, async move |this, cx| {
- if let Some(new_paths) = paths.await.ok().flatten() {
- if !new_paths.is_empty() {
- this.update(cx, |multi_workspace, cx| {
- multi_workspace.add_folders_to_project_group(&group_key, new_paths, cx);
- })?;
- }
- }
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
- }
-
- pub fn add_folders_to_project_group(
- &mut self,
- group_key: &ProjectGroupKey,
- new_paths: Vec<PathBuf>,
- cx: &mut Context<Self>,
- ) {
- let workspaces = self
- .workspaces_for_project_group(group_key, cx)
- .unwrap_or_default();
-
- let Some(group) = self
- .project_groups
- .iter()
- .find(|group| group.key == *group_key)
- else {
- return;
- };
-
- let existing_paths = group.key.path_list().paths();
- let new_paths: Vec<PathBuf> = new_paths
- .into_iter()
- .filter(|p| !existing_paths.contains(p))
- .collect();
-
- if new_paths.is_empty() {
- return;
- }
-
- let mut all_paths: Vec<PathBuf> = existing_paths.to_vec();
- all_paths.extend(new_paths.iter().cloned());
- let new_path_list = PathList::new(&all_paths);
- let new_key = ProjectGroupKey::new(group.key.host(), new_path_list);
-
- self.update_project_group_key(group_key, &new_key, cx);
-
- for workspace in workspaces {
- let project = workspace.read(cx).project().clone();
- for path in &new_paths {
- project
- .update(cx, |project, cx| {
- project.find_or_create_worktree(path, true, cx)
- })
- .detach_and_log_err(cx);
- }
- }
-
- self.serialize(cx);
- cx.notify();
- }
-
pub fn remove_project_group(
&mut self,
group_key: &ProjectGroupKey,
@@ -291,73 +291,6 @@ async fn test_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) {
});
}
-#[gpui::test]
-async fn test_groups_with_same_paths_merge(cx: &mut TestAppContext) {
- init_test(cx);
- let fs = FakeFs::new(cx.executor());
- fs.insert_tree("/a", json!({ "file.txt": "" })).await;
- fs.insert_tree("/b", json!({ "file.txt": "" })).await;
- let project_a = Project::test(fs.clone(), ["/a".as_ref()], cx).await;
- let project_b = Project::test(fs.clone(), ["/b".as_ref()], cx).await;
-
- let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
-
- // Open the sidebar so workspaces get grouped.
- multi_workspace.update(cx, |mw, cx| {
- mw.open_sidebar(cx);
- });
- cx.run_until_parked();
-
- // Add a second workspace, creating group_b with path [/b].
- let group_a_key = multi_workspace.update_in(cx, |mw, window, cx| {
- let group_a_key = mw.project_groups(cx)[0].key.clone();
- mw.test_add_workspace(project_b, window, cx);
- group_a_key
- });
- cx.run_until_parked();
-
- // Now add /b to group_a so it has [/a, /b].
- multi_workspace.update(cx, |mw, cx| {
- mw.add_folders_to_project_group(&group_a_key, vec!["/b".into()], cx);
- });
- cx.run_until_parked();
-
- // Verify we have two groups.
- multi_workspace.read_with(cx, |mw, cx| {
- assert_eq!(
- mw.project_groups(cx).len(),
- 2,
- "should have two groups before the merge"
- );
- });
-
- // After adding /b, group_a's key changed. Get the updated key.
- let group_a_key_updated = multi_workspace.read_with(cx, |mw, cx| {
- mw.project_groups(cx)
- .iter()
- .find(|g| g.key.path_list().paths().contains(&PathBuf::from("/a")))
- .unwrap()
- .key
- .clone()
- });
-
- // Remove /a from group_a, making its key [/b] — same as group_b.
- multi_workspace.update(cx, |mw, cx| {
- mw.remove_folder_from_project_group(&group_a_key_updated, Path::new("/a"), cx);
- });
- cx.run_until_parked();
-
- // The two groups now have identical keys [/b] and should have been merged.
- multi_workspace.read_with(cx, |mw, cx| {
- assert_eq!(
- mw.project_groups(cx).len(),
- 1,
- "groups with identical paths should be merged into one"
- );
- });
-}
-
#[gpui::test]
async fn test_adding_worktree_updates_project_group_key(cx: &mut TestAppContext) {
init_test(cx);