From 45d6a9595ff1a29a558a86da2b02069f9dd8633f Mon Sep 17 00:00:00 2001 From: Eric Holk Date: Fri, 3 Apr 2026 09:23:52 -0700 Subject: [PATCH] Track project groups in MultiWorkspace (#53032) This PR adds tracking of project groups to the MultiWorkspace and serialization/restoration of them. This will later be used by the sidebar to provide reliable reloading of threads across Zed reloads. Release Notes: - N/A --------- Co-authored-by: Max Brunsfeld Co-authored-by: Mikayla Maki --- crates/git_ui/src/worktree_picker.rs | 4 +- crates/project/src/project.rs | 63 +++++ crates/project_panel/src/project_panel.rs | 2 +- crates/recent_projects/src/recent_projects.rs | 16 +- crates/recent_projects/src/wsl_picker.rs | 2 +- crates/remote/src/remote_client.rs | 2 +- crates/remote/src/transport/docker.rs | 13 +- crates/remote/src/transport/mock.rs | 2 +- crates/remote/src/transport/ssh.rs | 4 +- crates/remote/src/transport/wsl.rs | 4 +- crates/sidebar/src/project_group_builder.rs | 61 +---- crates/sidebar/src/sidebar.rs | 9 + crates/util/src/path_list.rs | 2 +- crates/workspace/src/multi_workspace.rs | 89 +++---- crates/workspace/src/multi_workspace_tests.rs | 247 ++++++++++++++---- crates/workspace/src/persistence.rs | 2 + crates/workspace/src/persistence/model.rs | 25 +- crates/workspace/src/welcome.rs | 2 +- crates/workspace/src/workspace.rs | 19 +- 19 files changed, 382 insertions(+), 186 deletions(-) diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 2f600ae4c5620aa0d60cfc96b2d2c767b115f8aa..1b4497be1f4ea96bd4f0431c97bb538eda9faa57 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -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 }; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index bbfa7ffe208c198e76a9838695765c912977385d..41f57299835f37b001575b682118aa17a6516ad9 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -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::>(); + 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 { 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, +} + +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, 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 { + self.host.clone() + } +} + pub struct PathMatchCandidateSet { pub snapshot: Snapshot, pub include_ignored: bool, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index e9062364fc73ed6e266e3f8904be51eaaf5b6535..c2f1bb7131ad31ea75aee84bad17b7971d489a09 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -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, diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index b3f918e204c5600193cd01a0f7569888d333edd9..dc952764056f6465840825d2a1f0fce886f401c0 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -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!( diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index 9c08c4f5f4941a80afdd2d9cbb6f2c51ee8ec754..c53dd7c3fb68bc087216764536506f85117ffb36 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -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 }; diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index f31fc9ebec028b6a42a7cbc0d61cf9574a4a0f3c..e746d82aac857d3174a4bab14c937a7538b2f1b4 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -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), diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs index eddfa1216927dffa88f63c00c2e373233b426e83..6322cd9193d383cfcd3e9ff5cb93670bcd136023 100644 --- a/crates/remote/src/transport/docker.rs +++ b/crates/remote/src/transport/docker.rs @@ -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, diff --git a/crates/remote/src/transport/mock.rs b/crates/remote/src/transport/mock.rs index 06e13196583fef9743e3f337bfe9cd9acf0efbca..f567d24eb122f72b4dbb79cdeb2c98c744f02da4 100644 --- a/crates/remote/src/transport/mock.rs +++ b/crates/remote/src/transport/mock.rs @@ -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, } diff --git a/crates/remote/src/transport/ssh.rs b/crates/remote/src/transport/ssh.rs index 42cfc8f86dc34712e6b2cd0e4b5d8f379e443834..1884ea43b6492efba91623eb1ab4c5a1ed4d3de1 100644 --- a/crates/remote/src/transport/ssh.rs +++ b/crates/remote/src/transport/ssh.rs @@ -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, diff --git a/crates/remote/src/transport/wsl.rs b/crates/remote/src/transport/wsl.rs index 5a37e1c65bfe11221b60499779c57f0ce7dca364..1bbbaca2235c0bcf14c414a9419ab9dd92b4e814 100644 --- a/crates/remote/src/transport/wsl.rs +++ b/crates/remote/src/transport/wsl.rs @@ -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, diff --git a/crates/sidebar/src/project_group_builder.rs b/crates/sidebar/src/project_group_builder.rs index 9d06c7d31f1e1b34676db84a4f8e50131897f94d..20919647c185ce7014f056a99bb9c85ae595c560 100644 --- a/crates/sidebar/src/project_group_builder.rs +++ b/crates/sidebar/src/project_group_builder.rs @@ -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>, @@ -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, - project_groups: VecMap, + project_groups: VecMap, } 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, - 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 { + pub fn groups(&self) -> impl Iterator { self.project_groups.iter() } } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 7d7786fd59087f7d78088ae4517933ad089e8584..6816898ffc55bbf81b2c17719b3bde6eb8b58e68 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -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(); } diff --git a/crates/util/src/path_list.rs b/crates/util/src/path_list.rs index 0ea8bce6face2c248239c92e43a14ed010fb0c6e..47ade219c6bd4a2217f7ac00ecccfd92fe64c199 100644 --- a/crates/util/src/path_list.rs +++ b/crates/util/src/path_list.rs @@ -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, diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 10a5ce70ead2d5aea7cc21a9af53ee9f216859c3..6aa369774b63dd0d250ba67ba4a5b69a335a2de9 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -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>, active_workspace_index: usize, + project_group_keys: Vec, sidebar: Option>, sidebar_open: bool, sidebar_overlay: Option, @@ -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, ) { + 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 { + self.project_group_keys.iter() + } + pub fn workspace(&self) -> &Entity { &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, - window: &Window, - cx: &mut Context, - ) { - 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, cx: &mut Context) { 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::>(), 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>> { 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 { diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index 50161121719ec7b2835fd11e389f24860e57d8f5..3083c23f6e3add91b0389a961567fc88e2043678 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/crates/workspace/src/multi_workspace_tests.rs @@ -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); }); } diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index d38602ea768e8edc4f3de1ec439e67f0ee432a63..d9e440eb151bf7e8fc24f328b6ba73dc416a7c12 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -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, }, diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 6b55d09ebbc2375f8cce3f2b81bc4f1aa9620e76..61fe3bc4861d9ebb000681d8b4f887c3a45feebe 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -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, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SerializedProjectGroupKey { + pub path_list: SerializedPathList, + pub(crate) location: SerializedWorkspaceLocation, +} + +impl From 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, pub sidebar_open: bool, + pub project_group_keys: Vec, #[serde(default)] pub sidebar_state: Option, } diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index efd9b75a6802f888f43654e21006f202cc36c5a4..dceca3e85f4308952563e689c608c92e9f77144f 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -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(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ecc03806f7eeffbb62ad1340022e0ea475fe9531..e5b927cbbbc571966d2483e82d98ce61adb06cda 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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, 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.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();