Detailed changes
@@ -364,7 +364,7 @@ impl WorktreeListDelegate {
workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_workspace_for_paths(
- OpenMode::Replace,
+ OpenMode::Activate,
vec![new_worktree_path],
window,
cx,
@@ -418,7 +418,7 @@ impl WorktreeListDelegate {
return;
};
let open_mode = if replace_current_window {
- OpenMode::Replace
+ OpenMode::Activate
} else {
OpenMode::NewWindow
};
@@ -2349,6 +2349,22 @@ impl Project {
.find(|tree| tree.read(cx).root_name() == root_name)
}
+ pub fn project_group_key(&self, cx: &App) -> ProjectGroupKey {
+ let roots = self
+ .visible_worktrees(cx)
+ .map(|worktree| {
+ let snapshot = worktree.read(cx).snapshot();
+ snapshot
+ .root_repo_common_dir()
+ .and_then(|dir| Some(dir.parent()?.to_path_buf()))
+ .unwrap_or(snapshot.abs_path().to_path_buf())
+ })
+ .collect::<Vec<_>>();
+ let host = self.remote_connection_options(cx);
+ let path_list = PathList::new(&roots);
+ ProjectGroupKey::new(host, path_list)
+ }
+
#[inline]
pub fn worktree_root_names<'a>(&'a self, cx: &'a App) -> impl Iterator<Item = &'a str> {
self.visible_worktrees(cx)
@@ -6018,6 +6034,53 @@ impl Project {
}
}
+/// Identifies a project group by a set of paths the workspaces in this group
+/// have.
+///
+/// Paths are mapped to their main worktree path first so we can group
+/// workspaces by main repos.
+#[derive(PartialEq, Eq, Hash, Clone, Debug)]
+pub struct ProjectGroupKey {
+ paths: PathList,
+ host: Option<RemoteConnectionOptions>,
+}
+
+impl ProjectGroupKey {
+ /// Creates a new `ProjectGroupKey` with the given path list.
+ ///
+ /// The path list should point to the git main worktree paths for a project.
+ ///
+ /// This should be used only in a few places to make sure we can ensure the
+ /// main worktree path invariant. Namely, this should only be called from
+ /// [`Workspace`].
+ pub(crate) fn new(host: Option<RemoteConnectionOptions>, paths: PathList) -> Self {
+ Self { paths, host }
+ }
+
+ pub fn display_name(&self) -> SharedString {
+ let mut names = Vec::with_capacity(self.paths.paths().len());
+ for abs_path in self.paths.paths() {
+ if let Some(name) = abs_path.file_name() {
+ names.push(name.to_string_lossy().to_string());
+ }
+ }
+ if names.is_empty() {
+ // TODO: Can we do something better in this case?
+ "Empty Workspace".into()
+ } else {
+ names.join(", ").into()
+ }
+ }
+
+ pub fn path_list(&self) -> &PathList {
+ &self.paths
+ }
+
+ pub fn host(&self) -> Option<RemoteConnectionOptions> {
+ self.host.clone()
+ }
+}
+
pub struct PathMatchCandidateSet {
pub snapshot: Snapshot,
pub include_ignored: bool,
@@ -7126,7 +7126,7 @@ impl Render for ProjectPanel {
.workspace
.update(cx, |workspace, cx| {
workspace.open_workspace_for_paths(
- OpenMode::Replace,
+ OpenMode::Activate,
external_paths.paths().to_owned(),
window,
cx,
@@ -1160,7 +1160,7 @@ impl PickerDelegate for RecentProjectsDelegate {
.update(cx, |multi_workspace, window, cx| {
multi_workspace.open_project(
paths,
- OpenMode::Replace,
+ OpenMode::Activate,
window,
cx,
)
@@ -2122,14 +2122,12 @@ mod tests {
cx.dispatch_action(*multi_workspace, menu::Confirm);
cx.run_until_parked();
- // prepare_to_close triggers a save prompt for the dirty buffer.
- // Choose "Don't Save" (index 2) to discard and continue replacing.
+ // 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 prompt to save dirty buffer before replacing workspace"
+ !cx.has_pending_prompt(),
+ "Should not prompt in multi-workspace mode — dirty workspace is kept"
);
- cx.simulate_prompt_answer("Don't Save");
- cx.run_until_parked();
multi_workspace
.update(cx, |multi_workspace, _, cx| {
@@ -2143,8 +2141,8 @@ mod tests {
);
assert!(
- !multi_workspace.workspaces().contains(&dirty_workspace),
- "The original dirty workspace should have been replaced"
+ multi_workspace.workspaces().contains(&dirty_workspace),
+ "The dirty workspace should still be present in multi-workspace mode"
);
assert!(
@@ -246,7 +246,7 @@ impl WslOpenModal {
false => !secondary,
};
let open_mode = if replace_current_window {
- workspace::OpenMode::Replace
+ workspace::OpenMode::Activate
} else {
workspace::OpenMode::NewWindow
};
@@ -1273,7 +1273,7 @@ impl ConnectionPool {
}
}
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum RemoteConnectionOptions {
Ssh(SshConnectionOptions),
Wsl(WslConnectionOptions),
@@ -30,7 +30,18 @@ use crate::{
transport::parse_platform,
};
-#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
+#[derive(
+ Debug,
+ Default,
+ Clone,
+ PartialEq,
+ Eq,
+ Hash,
+ PartialOrd,
+ Ord,
+ serde::Serialize,
+ serde::Deserialize,
+)]
pub struct DockerConnectionOptions {
pub name: String,
pub container_id: String,
@@ -56,7 +56,7 @@ use std::{
use util::paths::{PathStyle, RemotePathBuf};
/// Unique identifier for a mock connection.
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct MockConnectionOptions {
pub id: u64,
}
@@ -45,7 +45,7 @@ pub(crate) struct SshRemoteConnection {
_temp_dir: TempDir,
}
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum SshConnectionHost {
IpAddr(IpAddr),
Hostname(String),
@@ -102,7 +102,7 @@ fn bracket_ipv6(host: &str) -> String {
}
}
-#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct SshConnectionOptions {
pub host: SshConnectionHost,
pub username: Option<String>,
@@ -28,7 +28,9 @@ use util::{
shell_builder::ShellBuilder,
};
-#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, schemars::JsonSchema)]
+#[derive(
+ Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
+)]
pub struct WslConnectionOptions {
pub distro_name: String,
pub user: Option<String>,
@@ -9,46 +9,14 @@
//! lookup and mapping.
use collections::{HashMap, HashSet, vecmap::VecMap};
+use gpui::{App, Entity};
+use project::ProjectGroupKey;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
-
-use gpui::{App, Entity};
-use ui::SharedString;
use workspace::{MultiWorkspace, PathList, Workspace};
-/// Identifies a project group by a set of paths the workspaces in this group
-/// have.
-///
-/// Paths are mapped to their main worktree path first so we can group
-/// workspaces by main repos.
-#[derive(PartialEq, Eq, Hash, Clone)]
-pub struct ProjectGroupName {
- path_list: PathList,
-}
-
-impl ProjectGroupName {
- pub fn display_name(&self) -> SharedString {
- let mut names = Vec::with_capacity(self.path_list.paths().len());
- for abs_path in self.path_list.paths() {
- if let Some(name) = abs_path.file_name() {
- names.push(name.to_string_lossy().to_string());
- }
- }
- if names.is_empty() {
- // TODO: Can we do something better in this case?
- "Empty Workspace".into()
- } else {
- names.join(", ").into()
- }
- }
-
- pub fn path_list(&self) -> &PathList {
- &self.path_list
- }
-}
-
#[derive(Default)]
pub struct ProjectGroup {
pub workspaces: Vec<Entity<Workspace>>,
@@ -88,7 +56,7 @@ impl ProjectGroup {
pub struct ProjectGroupBuilder {
/// Maps git repositories' work_directory_abs_path to their original_repo_abs_path
directory_mappings: HashMap<PathBuf, PathBuf>,
- project_groups: VecMap<ProjectGroupName, ProjectGroup>,
+ project_groups: VecMap<ProjectGroupKey, ProjectGroup>,
}
impl ProjectGroupBuilder {
@@ -111,7 +79,7 @@ impl ProjectGroupBuilder {
// Second pass: group each workspace using canonical paths derived
// from the full set of mappings.
for workspace in mw.workspaces() {
- let group_name = builder.canonical_workspace_paths(workspace, cx);
+ let group_name = workspace.read(cx).project_group_key(cx);
builder
.project_group_entry(&group_name)
.add_workspace(workspace, cx);
@@ -119,7 +87,7 @@ impl ProjectGroupBuilder {
builder
}
- fn project_group_entry(&mut self, name: &ProjectGroupName) -> &mut ProjectGroup {
+ fn project_group_entry(&mut self, name: &ProjectGroupKey) -> &mut ProjectGroup {
self.project_groups.entry_ref(name).or_insert_default()
}
@@ -150,23 +118,6 @@ impl ProjectGroupBuilder {
}
}
- /// Derives the canonical group name for a workspace by canonicalizing
- /// each of its root paths using the builder's directory mappings.
- fn canonical_workspace_paths(
- &self,
- workspace: &Entity<Workspace>,
- cx: &App,
- ) -> ProjectGroupName {
- let root_paths = workspace.read(cx).root_paths(cx);
- let paths: Vec<_> = root_paths
- .iter()
- .map(|p| self.canonicalize_path(p).to_path_buf())
- .collect();
- ProjectGroupName {
- path_list: PathList::new(&paths),
- }
- }
-
pub fn canonicalize_path<'a>(&'a self, path: &'a Path) -> &'a Path {
self.directory_mappings
.get(path)
@@ -203,7 +154,7 @@ impl ProjectGroupBuilder {
PathList::new(&paths)
}
- pub fn groups(&self) -> impl Iterator<Item = (&ProjectGroupName, &ProjectGroup)> {
+ pub fn groups(&self) -> impl Iterator<Item = (&ProjectGroupKey, &ProjectGroup)> {
self.project_groups.iter()
}
}
@@ -3829,6 +3829,15 @@ pub fn dump_workspace_info(
.map(|mw| mw.read(cx).active_workspace_index());
writeln!(output, "MultiWorkspace: {} workspace(s)", workspaces.len()).ok();
+
+ if let Some(mw) = &multi_workspace {
+ let keys: Vec<_> = mw.read(cx).project_group_keys().cloned().collect();
+ writeln!(output, "Project group keys ({}):", keys.len()).ok();
+ for key in keys {
+ writeln!(output, " - {key:?}").ok();
+ }
+ }
+
if let Some(index) = active_index {
writeln!(output, "Active workspace index: {index}").ok();
}
@@ -38,7 +38,7 @@ impl Hash for PathList {
}
}
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedPathList {
pub paths: String,
pub order: String,
@@ -5,9 +5,9 @@ use gpui::{
ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId,
actions, deferred, px,
};
-use project::DisableAiSettings;
#[cfg(any(test, feature = "test-support"))]
use project::Project;
+use project::{DisableAiSettings, ProjectGroupKey};
use settings::Settings;
pub use settings::SidebarSide;
use std::future::Future;
@@ -26,6 +26,7 @@ const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
use crate::{
CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, OpenMode,
Panel, Workspace, WorkspaceId, client_side_decorations,
+ persistence::model::MultiWorkspaceState,
};
actions!(
@@ -222,6 +223,7 @@ pub struct MultiWorkspace {
window_id: WindowId,
workspaces: Vec<Entity<Workspace>>,
active_workspace_index: usize,
+ project_group_keys: Vec<ProjectGroupKey>,
sidebar: Option<Box<dyn SidebarHandle>>,
sidebar_open: bool,
sidebar_overlay: Option<AnyView>,
@@ -269,6 +271,7 @@ impl MultiWorkspace {
});
Self {
window_id: window.window_handle().window_id(),
+ project_group_keys: vec![workspace.read(cx).project_group_key(cx)],
workspaces: vec![workspace],
active_workspace_index: 0,
sidebar: None,
@@ -438,6 +441,20 @@ impl MultiWorkspace {
window: &Window,
cx: &mut Context<Self>,
) {
+ let project = workspace.read(cx).project().clone();
+ cx.subscribe_in(&project, window, {
+ let workspace = workspace.downgrade();
+ move |this, _project, event, _window, cx| match event {
+ project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => {
+ if let Some(workspace) = workspace.upgrade() {
+ this.add_project_group_key(workspace.read(cx).project_group_key(cx));
+ }
+ }
+ _ => {}
+ }
+ })
+ .detach();
+
cx.subscribe_in(workspace, window, |this, workspace, event, window, cx| {
if let WorkspaceEvent::Activate = event {
this.activate(workspace.clone(), window, cx);
@@ -446,6 +463,17 @@ impl MultiWorkspace {
.detach();
}
+ pub fn add_project_group_key(&mut self, project_group_key: ProjectGroupKey) {
+ if self.project_group_keys.contains(&project_group_key) {
+ return;
+ }
+ self.project_group_keys.push(project_group_key);
+ }
+
+ pub fn project_group_keys(&self) -> impl Iterator<Item = &ProjectGroupKey> {
+ self.project_group_keys.iter()
+ }
+
pub fn workspace(&self) -> &Entity<Workspace> {
&self.workspaces[self.active_workspace_index]
}
@@ -492,48 +520,6 @@ impl MultiWorkspace {
cx.notify();
}
- /// Replaces the currently active workspace with a new one. If the
- /// workspace is already in the list, this just switches to it.
- pub fn replace(
- &mut self,
- workspace: Entity<Workspace>,
- window: &Window,
- cx: &mut Context<Self>,
- ) {
- if !self.multi_workspace_enabled(cx) {
- self.set_single_workspace(workspace, cx);
- return;
- }
-
- if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
- let changed = self.active_workspace_index != index;
- self.active_workspace_index = index;
- if changed {
- cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
- self.serialize(cx);
- }
- cx.notify();
- return;
- }
-
- let old_workspace = std::mem::replace(
- &mut self.workspaces[self.active_workspace_index],
- workspace.clone(),
- );
-
- let old_entity_id = old_workspace.entity_id();
- self.detach_workspace(&old_workspace, cx);
-
- Self::subscribe_to_workspace(&workspace, window, cx);
- self.sync_sidebar_to_workspace(&workspace, cx);
-
- cx.emit(MultiWorkspaceEvent::WorkspaceRemoved(old_entity_id));
- cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
- cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
- self.serialize(cx);
- cx.notify();
- }
-
fn set_single_workspace(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
self.workspaces[0] = workspace;
self.active_workspace_index = 0;
@@ -553,12 +539,16 @@ impl MultiWorkspace {
if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
index
} else {
+ let project_group_key = workspace.read(cx).project().read(cx).project_group_key(cx);
+
Self::subscribe_to_workspace(&workspace, window, cx);
self.sync_sidebar_to_workspace(&workspace, cx);
let weak_self = cx.weak_entity();
workspace.update(cx, |workspace, cx| {
workspace.set_multi_workspace(weak_self, cx);
});
+
+ self.add_project_group_key(project_group_key);
self.workspaces.push(workspace.clone());
cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
cx.notify();
@@ -625,8 +615,13 @@ impl MultiWorkspace {
self._serialize_task = Some(cx.spawn(async move |this, cx| {
let Some((window_id, state)) = this
.read_with(cx, |this, cx| {
- let state = crate::persistence::model::MultiWorkspaceState {
+ let state = MultiWorkspaceState {
active_workspace_id: this.workspace().read(cx).database_id(),
+ project_group_keys: this
+ .project_group_keys()
+ .cloned()
+ .map(Into::into)
+ .collect::<Vec<_>>(),
sidebar_open: this.sidebar_open,
sidebar_state: this.sidebar.as_ref().and_then(|s| s.serialized_state(cx)),
};
@@ -894,6 +889,7 @@ impl MultiWorkspace {
});
}
+ // TODO: Move group to a new window?
fn move_active_workspace_to_new_window(
&mut self,
_: &MoveWorkspaceToNewWindow,
@@ -913,12 +909,11 @@ impl MultiWorkspace {
) -> Task<Result<Entity<Workspace>>> {
let workspace = self.workspace().clone();
- let needs_close_prompt =
- open_mode == OpenMode::Replace || !self.multi_workspace_enabled(cx);
+ let needs_close_prompt = !self.multi_workspace_enabled(cx);
let open_mode = if self.multi_workspace_enabled(cx) {
open_mode
} else {
- OpenMode::Replace
+ OpenMode::Activate
};
if needs_close_prompt {
@@ -2,7 +2,8 @@ use super::*;
use feature_flags::FeatureFlagAppExt;
use fs::FakeFs;
use gpui::TestAppContext;
-use project::DisableAiSettings;
+use project::{DisableAiSettings, ProjectGroupKey};
+use serde_json::json;
use settings::SettingsStore;
fn init_test(cx: &mut TestAppContext) {
@@ -87,86 +88,232 @@ async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContex
}
#[gpui::test]
-async fn test_replace(cx: &mut TestAppContext) {
+async fn test_project_group_keys_initial(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
- let project_a = Project::test(fs.clone(), [], cx).await;
- let project_b = Project::test(fs.clone(), [], cx).await;
- let project_c = Project::test(fs.clone(), [], cx).await;
- let project_d = Project::test(fs.clone(), [], cx).await;
+ fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+ let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
+
+ let expected_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
let (multi_workspace, cx) =
- cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+ multi_workspace.read_with(cx, |mw, _cx| {
+ let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
+ assert_eq!(keys.len(), 1, "should have exactly one key on creation");
+ assert_eq!(*keys[0], expected_key);
+ });
+}
- let workspace_a_id = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id());
+#[gpui::test]
+async fn test_project_group_keys_add_workspace(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+ fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
+ let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
+ let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await;
- // Replace the only workspace (single-workspace case).
- let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
- let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx));
- mw.replace(workspace.clone(), &*window, cx);
- workspace
+ 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));
+ assert_ne!(
+ key_a, key_b,
+ "different roots should produce different keys"
+ );
+
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+ multi_workspace.read_with(cx, |mw, _cx| {
+ assert_eq!(mw.project_group_keys().count(), 1);
+ });
+
+ // Adding a workspace with a different project root adds a new key.
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(project_b, window, cx);
});
multi_workspace.read_with(cx, |mw, _cx| {
- assert_eq!(mw.workspaces().len(), 1);
+ let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
assert_eq!(
- mw.workspaces()[0].entity_id(),
- workspace_b.entity_id(),
- "slot should now be project_b"
- );
- assert_ne!(
- mw.workspaces()[0].entity_id(),
- workspace_a_id,
- "project_a should be gone"
+ keys.len(),
+ 2,
+ "should have two keys after adding a second workspace"
);
+ assert_eq!(*keys[0], key_a);
+ assert_eq!(*keys[1], key_b);
});
+}
+
+#[gpui::test]
+async fn test_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+ let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
+ // A second project entity pointing at the same path produces the same key.
+ let project_a2 = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
- // Add project_c as a second workspace, then replace it with project_d.
- let workspace_c = multi_workspace.update_in(cx, |mw, window, cx| {
- mw.test_add_workspace(project_c.clone(), window, cx)
+ let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
+ let key_a2 = project_a2.read_with(cx, |p, cx| p.project_group_key(cx));
+ assert_eq!(key_a, key_a2, "same root path should produce the same key");
+
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
+
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(project_a2, window, cx);
});
multi_workspace.read_with(cx, |mw, _cx| {
- assert_eq!(mw.workspaces().len(), 2);
- assert_eq!(mw.active_workspace_index(), 1);
+ let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
+ assert_eq!(
+ keys.len(),
+ 1,
+ "duplicate key should not be added when a workspace with the same root is inserted"
+ );
});
+}
- let workspace_d = multi_workspace.update_in(cx, |mw, window, cx| {
- let workspace = cx.new(|cx| Workspace::test_new(project_d.clone(), window, cx));
- mw.replace(workspace.clone(), &*window, cx);
- workspace
- });
+#[gpui::test]
+async fn test_project_group_keys_on_worktree_added(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+ fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
+ let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
+
+ let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+
+ // Add a second worktree to the same project.
+ let (worktree, _) = project
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree("/root_b", true, cx)
+ })
+ .await
+ .unwrap();
+ worktree
+ .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+ .await;
+ cx.run_until_parked();
+
+ let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+ assert_ne!(
+ initial_key, updated_key,
+ "key should change after adding a worktree"
+ );
multi_workspace.read_with(cx, |mw, _cx| {
- assert_eq!(mw.workspaces().len(), 2, "should still have 2 workspaces");
- assert_eq!(mw.active_workspace_index(), 1);
+ let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
assert_eq!(
- mw.workspaces()[1].entity_id(),
- workspace_d.entity_id(),
- "active slot should now be project_d"
- );
- assert_ne!(
- mw.workspaces()[1].entity_id(),
- workspace_c.entity_id(),
- "project_c should be gone"
+ keys.len(),
+ 2,
+ "should have both the original and updated key"
);
+ assert_eq!(*keys[0], initial_key);
+ assert_eq!(*keys[1], updated_key);
});
+}
- // Replace with workspace_b which is already in the list — should just switch.
- multi_workspace.update_in(cx, |mw, window, cx| {
- mw.replace(workspace_b.clone(), &*window, cx);
+#[gpui::test]
+async fn test_project_group_keys_on_worktree_removed(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+ fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
+ let project = Project::test(fs, ["/root_a".as_ref(), "/root_b".as_ref()], cx).await;
+
+ let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+
+ // Remove one worktree.
+ let worktree_b_id = project.read_with(cx, |project, cx| {
+ project
+ .worktrees(cx)
+ .find(|wt| wt.read(cx).root_name().as_unix_str() == "root_b")
+ .unwrap()
+ .read(cx)
+ .id()
+ });
+ project.update(cx, |project, cx| {
+ project.remove_worktree(worktree_b_id, cx);
});
+ cx.run_until_parked();
+
+ let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
+ assert_ne!(
+ initial_key, updated_key,
+ "key should change after removing a worktree"
+ );
multi_workspace.read_with(cx, |mw, _cx| {
+ let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
assert_eq!(
- mw.workspaces().len(),
+ keys.len(),
2,
- "no workspace should be added or removed"
+ "should accumulate both the original and post-removal key"
);
+ assert_eq!(*keys[0], initial_key);
+ assert_eq!(*keys[1], updated_key);
+ });
+}
+
+#[gpui::test]
+async fn test_project_group_keys_across_multiple_workspaces_and_worktree_changes(
+ cx: &mut TestAppContext,
+) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
+ fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
+ fs.insert_tree("/root_c", json!({ "file.txt": "" })).await;
+ let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
+ let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await;
+
+ 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 (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+
+ multi_workspace.update_in(cx, |mw, window, cx| {
+ mw.test_add_workspace(project_b, window, cx);
+ });
+
+ multi_workspace.read_with(cx, |mw, _cx| {
+ assert_eq!(mw.project_group_keys().count(), 2);
+ });
+
+ // Now add a worktree to project_a. This should produce a third key.
+ let (worktree, _) = project_a
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree("/root_c", true, cx)
+ })
+ .await
+ .unwrap();
+ worktree
+ .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+ .await;
+ cx.run_until_parked();
+
+ let key_a_updated = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
+ assert_ne!(key_a, key_a_updated);
+
+ multi_workspace.read_with(cx, |mw, _cx| {
+ let keys: Vec<&ProjectGroupKey> = mw.project_group_keys().collect();
assert_eq!(
- mw.active_workspace_index(),
- 0,
- "should have switched to workspace_b"
+ keys.len(),
+ 3,
+ "should have key_a, key_b, and the updated key_a with root_c"
);
+ assert_eq!(*keys[0], key_a);
+ assert_eq!(*keys[1], key_b);
+ assert_eq!(*keys[2], key_a_updated);
});
}
@@ -3993,6 +3993,7 @@ mod tests {
window_10,
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(2)),
+ project_group_keys: vec![],
sidebar_open: true,
sidebar_state: None,
},
@@ -4004,6 +4005,7 @@ mod tests {
window_20,
MultiWorkspaceState {
active_workspace_id: Some(WorkspaceId(3)),
+ project_group_keys: vec![],
sidebar_open: false,
sidebar_state: None,
},
@@ -13,7 +13,7 @@ use db::sqlez::{
use gpui::{AsyncWindowContext, Entity, WeakEntity, WindowId};
use language::{Toolchain, ToolchainScope};
-use project::{Project, debugger::breakpoint_store::SourceBreakpoint};
+use project::{Project, ProjectGroupKey, debugger::breakpoint_store::SourceBreakpoint};
use remote::RemoteConnectionOptions;
use serde::{Deserialize, Serialize};
use std::{
@@ -21,7 +21,7 @@ use std::{
path::{Path, PathBuf},
sync::Arc,
};
-use util::ResultExt;
+use util::{ResultExt, path_list::SerializedPathList};
use uuid::Uuid;
#[derive(
@@ -36,7 +36,7 @@ pub(crate) enum RemoteConnectionKind {
Docker,
}
-#[derive(Debug, PartialEq, Clone)]
+#[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
pub enum SerializedWorkspaceLocation {
Local,
Remote(RemoteConnectionOptions),
@@ -59,11 +59,30 @@ pub struct SessionWorkspace {
pub window_id: Option<WindowId>,
}
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct SerializedProjectGroupKey {
+ pub path_list: SerializedPathList,
+ pub(crate) location: SerializedWorkspaceLocation,
+}
+
+impl From<ProjectGroupKey> for SerializedProjectGroupKey {
+ fn from(value: ProjectGroupKey) -> Self {
+ SerializedProjectGroupKey {
+ path_list: value.path_list().serialize(),
+ location: match value.host() {
+ Some(host) => SerializedWorkspaceLocation::Remote(host),
+ None => SerializedWorkspaceLocation::Local,
+ },
+ }
+ }
+}
+
/// Per-window state for a MultiWorkspace, persisted to KVP.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct MultiWorkspaceState {
pub active_workspace_id: Option<WorkspaceId>,
pub sidebar_open: bool,
+ pub project_group_keys: Vec<SerializedProjectGroupKey>,
#[serde(default)]
pub sidebar_state: Option<String>,
}
@@ -326,7 +326,7 @@ impl WelcomePage {
self.workspace
.update(cx, |workspace, cx| {
workspace
- .open_workspace_for_paths(OpenMode::Replace, paths, window, cx)
+ .open_workspace_for_paths(OpenMode::Activate, paths, window, cx)
.detach_and_log_err(cx);
})
.log_err();
@@ -90,8 +90,8 @@ pub use persistence::{
};
use postage::stream::Stream;
use project::{
- DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
- WorktreeSettings,
+ DirectoryLister, Project, ProjectEntryId, ProjectGroupKey, ProjectPath, ResolvedPath, Worktree,
+ WorktreeId, WorktreeSettings,
debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
project_settings::ProjectSettings,
toolchain_store::ToolchainStoreEvent,
@@ -672,7 +672,7 @@ fn prompt_and_open_paths(app_state: Arc<AppState>, options: PathPromptOptions, c
None,
None,
None,
- OpenMode::Replace,
+ OpenMode::Activate,
cx,
);
cx.spawn(async move |cx| {
@@ -713,7 +713,7 @@ pub fn prompt_for_open_path_and_open(
if let Some(handle) = multi_workspace_handle {
if let Some(task) = handle
.update(cx, |multi_workspace, window, cx| {
- multi_workspace.open_project(paths, OpenMode::Replace, window, cx)
+ multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
})
.log_err()
{
@@ -1380,8 +1380,6 @@ pub enum OpenMode {
/// Add to the window's multi workspace and activate it.
#[default]
Activate,
- /// Replace the currently active workspace, and any of it's linked workspaces
- Replace,
}
impl Workspace {
@@ -1921,9 +1919,6 @@ impl Workspace {
workspace
});
match open_mode {
- OpenMode::Replace => {
- multi_workspace.replace(workspace.clone(), &*window, cx);
- }
OpenMode::Activate => {
multi_workspace.activate(workspace.clone(), window, cx);
}
@@ -2056,6 +2051,10 @@ impl Workspace {
})
}
+ pub fn project_group_key(&self, cx: &App) -> ProjectGroupKey {
+ self.project.read(cx).project_group_key(cx)
+ }
+
pub fn weak_handle(&self) -> WeakEntity<Self> {
self.weak_self.clone()
}
@@ -3409,7 +3408,7 @@ impl Workspace {
let workspace_is_empty = !is_remote && !has_worktree && !has_dirty_items;
if workspace_is_empty {
- open_mode = OpenMode::Replace;
+ open_mode = OpenMode::Activate;
}
let app_state = self.app_state.clone();