Fix a reachability bug in the sidebar (#53785)

Mikayla Maki created

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #ISSUE

Release Notes:

- N/A

Change summary

crates/sidebar/src/sidebar_tests.rs     | 113 ++++++++++++++++++++++++++
crates/workspace/src/multi_workspace.rs |  11 ++
2 files changed, 122 insertions(+), 2 deletions(-)

Detailed changes

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -8777,6 +8777,115 @@ async fn test_worktree_add_only_migrates_threads_for_same_folder_paths(cx: &mut
     });
 }
 
+#[gpui::test]
+async fn test_linked_worktree_workspace_reachable_after_adding_worktree_to_project(
+    cx: &mut TestAppContext,
+) {
+    // When a linked worktree is opened as its own workspace and then a new
+    // folder is added to the main project group, the linked worktree
+    // workspace must still be reachable from some sidebar entry.
+    let (_fs, project) = init_multi_project_test(&["/my-project"], cx).await;
+    let fs = _fs.clone();
+
+    // Set up git worktree infrastructure.
+    fs.insert_tree(
+        "/my-project/.git/worktrees/wt-0",
+        serde_json::json!({
+            "commondir": "../../",
+            "HEAD": "ref: refs/heads/wt-0",
+        }),
+    )
+    .await;
+    fs.insert_tree(
+        "/worktrees/wt-0",
+        serde_json::json!({
+            ".git": "gitdir: /my-project/.git/worktrees/wt-0",
+            "src": {},
+        }),
+    )
+    .await;
+    fs.add_linked_worktree_for_repo(
+        Path::new("/my-project/.git"),
+        false,
+        git::repository::Worktree {
+            path: PathBuf::from("/worktrees/wt-0"),
+            ref_name: Some("refs/heads/wt-0".into()),
+            sha: "aaa".into(),
+            is_main: false,
+        },
+    )
+    .await;
+
+    // Re-scan so the main project discovers the linked worktree.
+    project.update(cx, |p, cx| p.git_scans_complete(cx)).await;
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+    let sidebar = setup_sidebar(&multi_workspace, cx);
+
+    // Open the linked worktree as its own workspace.
+    let worktree_project = project::Project::test(
+        fs.clone() as Arc<dyn fs::Fs>,
+        ["/worktrees/wt-0".as_ref()],
+        cx,
+    )
+    .await;
+    worktree_project
+        .update(cx, |p, cx| p.git_scans_complete(cx))
+        .await;
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(worktree_project.clone(), window, cx);
+    });
+    cx.run_until_parked();
+
+    // Both workspaces should be reachable.
+    let workspace_count = multi_workspace.read_with(cx, |mw, _| mw.workspaces().count());
+    assert_eq!(workspace_count, 2, "should have 2 workspaces");
+
+    // Add a new folder to the main project, changing the project group key.
+    fs.insert_tree(
+        "/other-project",
+        serde_json::json!({ ".git": {}, "src": {} }),
+    )
+    .await;
+    project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/other-project", true, cx)
+        })
+        .await
+        .expect("should add worktree");
+    cx.run_until_parked();
+
+    sidebar.update_in(cx, |sidebar, _window, cx| sidebar.update_entries(cx));
+    cx.run_until_parked();
+
+    // The linked worktree workspace must still be reachable.
+    let entries = visible_entries_as_strings(&sidebar, cx);
+    let mw_workspaces: Vec<_> = multi_workspace.read_with(cx, |mw, _| {
+        mw.workspaces().map(|ws| ws.entity_id()).collect()
+    });
+    sidebar.read_with(cx, |sidebar, cx| {
+        let multi_workspace = multi_workspace.read(cx);
+        let reachable: std::collections::HashSet<gpui::EntityId> = sidebar
+            .contents
+            .entries
+            .iter()
+            .flat_map(|entry| entry.reachable_workspaces(multi_workspace, cx))
+            .map(|ws| ws.entity_id())
+            .collect();
+        let all: std::collections::HashSet<gpui::EntityId> =
+            mw_workspaces.iter().copied().collect();
+        let unreachable = &all - &reachable;
+        assert!(
+            unreachable.is_empty(),
+            "all workspaces should be reachable after adding folder; \
+             unreachable: {:?}, entries: {:?}",
+            unreachable,
+            entries,
+        );
+    });
+}
+
 mod property_test {
     use super::*;
     use gpui::proptest::prelude::*;
@@ -9582,11 +9691,11 @@ mod property_test {
     }
 
     #[gpui::property_test(config = ProptestConfig {
-        cases: 50,
+        cases: 20,
         ..Default::default()
     })]
     async fn test_sidebar_invariants(
-        #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..5)]
+        #[strategy = gpui::proptest::collection::vec(0u32..DISTRIBUTION_SLOTS * 10, 1..10)]
         raw_operations: Vec<u32>,
         cx: &mut TestAppContext,
     ) {

crates/workspace/src/multi_workspace.rs 🔗

@@ -666,6 +666,17 @@ impl MultiWorkspace {
                 group.key = new_key.clone();
             }
         }
+
+        // If another retained workspace still has the old key (e.g. a
+        // linked worktree workspace), re-create the old group so it
+        // remains reachable in the sidebar.
+        let other_workspace_needs_old_key = self
+            .retained_workspaces
+            .iter()
+            .any(|ws| ws.read(cx).project_group_key(cx) == *old_key);
+        if other_workspace_needs_old_key {
+            self.ensure_project_group_state(old_key.clone());
+        }
     }
 
     /// Re-keys a project group and emits `ProjectGroupKeyUpdated` so