sidebar: Fallback to main git worktree path when opening thread in deleted git worktree (#53899)

Anthony Eid created

### Summary

This PR is the first step in improving Zed's error handling when a user
opens an old thread that's associated with a deleted git worktree.
Before, the thread would open in an empty project with a broken state.
This PR instead opens the thread in the workspace associated with the
thread’s main git worktree when available.

### Follow ups

1. Implement remote support for this fallback 
2. Update `ThreadMetadataStore` database to set paths from deleted
worktree to the main worktree
3. If the main git worktree is deleted as well, fallback to the
currently active workspace

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/recent_projects/src/recent_projects.rs |  2 
crates/sidebar/src/sidebar.rs                 | 41 +++++++----
crates/workspace/src/multi_workspace.rs       | 61 +++++++++++++++++
crates/workspace/src/multi_workspace_tests.rs | 71 ++++++++++++++++++++
crates/workspace/src/persistence.rs           |  2 
crates/workspace/src/workspace.rs             |  6 +
6 files changed, 161 insertions(+), 22 deletions(-)

Detailed changes

crates/recent_projects/src/recent_projects.rs πŸ”—

@@ -1130,6 +1130,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                     return;
                 };
 
+                let key = key.clone();
                 let path_list = key.path_list().clone();
                 if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
                     cx.defer(move |cx| {
@@ -1137,6 +1138,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                             .update(cx, |multi_workspace, window, cx| {
                                 multi_workspace.find_or_create_local_workspace(
                                     path_list,
+                                    Some(key.clone()),
                                     &[],
                                     window,
                                     cx,

crates/sidebar/src/sidebar.rs πŸ”—

@@ -2995,15 +2995,17 @@ impl Sidebar {
                     ListEntry::Thread(t)
                         if !t.is_draft && t.metadata.session_id.as_ref() != Some(session_id) =>
                     {
-                        let workspace_paths = match &t.workspace {
-                            ThreadEntryWorkspace::Open(ws) => {
-                                PathList::new(&ws.read(cx).root_paths(cx))
-                            }
-                            ThreadEntryWorkspace::Closed { folder_paths, .. } => {
-                                folder_paths.clone()
-                            }
+                        let (workspace_paths, project_group_key) = match &t.workspace {
+                            ThreadEntryWorkspace::Open(ws) => (
+                                PathList::new(&ws.read(cx).root_paths(cx)),
+                                ws.read(cx).project_group_key(cx),
+                            ),
+                            ThreadEntryWorkspace::Closed {
+                                folder_paths,
+                                project_group_key,
+                            } => (folder_paths.clone(), project_group_key.clone()),
                         };
-                        Some((t.metadata.clone(), workspace_paths))
+                        Some((t.metadata.clone(), workspace_paths, project_group_key))
                     }
                     _ => None,
                 })
@@ -3119,13 +3121,16 @@ impl Sidebar {
             let multi_workspace = self.multi_workspace.upgrade().unwrap();
             let session_id = session_id.clone();
 
-            let fallback_paths = neighbor
+            let (fallback_paths, project_group_key) = neighbor
                 .as_ref()
-                .map(|(_, paths)| paths.clone())
+                .map(|(_, paths, project_group_key)| (paths.clone(), project_group_key.clone()))
                 .unwrap_or_else(|| {
                     workspaces_to_remove
                         .first()
-                        .map(|ws| ws.read(cx).project_group_key(cx).path_list().clone())
+                        .map(|ws| {
+                            let key = ws.read(cx).project_group_key(cx);
+                            (key.path_list().clone(), key)
+                        })
                         .unwrap_or_default()
                 });
 
@@ -3134,14 +3139,20 @@ impl Sidebar {
                 mw.remove(
                     workspaces_to_remove,
                     move |this, window, cx| {
-                        this.find_or_create_local_workspace(fallback_paths, &excluded, window, cx)
+                        this.find_or_create_local_workspace(
+                            fallback_paths,
+                            Some(project_group_key),
+                            &excluded,
+                            window,
+                            cx,
+                        )
                     },
                     window,
                     cx,
                 )
             });
 
-            let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
+            let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata);
             let thread_folder_paths = thread_folder_paths.clone();
             cx.spawn_in(window, async move |this, cx| {
                 if !remove_task.await? {
@@ -3172,7 +3183,7 @@ impl Sidebar {
             .detach_and_log_err(cx);
         } else if !close_item_tasks.is_empty() {
             let session_id = session_id.clone();
-            let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
+            let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata);
             let thread_folder_paths = thread_folder_paths.clone();
             cx.spawn_in(window, async move |this, cx| {
                 for task in close_item_tasks {
@@ -3198,7 +3209,7 @@ impl Sidebar {
             })
             .detach_and_log_err(cx);
         } else {
-            let neighbor_metadata = neighbor.map(|(metadata, _)| metadata);
+            let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata);
             let in_flight = thread_id
                 .and_then(|tid| self.start_archive_worktree_task(tid, roots_to_archive, cx));
             self.archive_and_activate(

crates/workspace/src/multi_workspace.rs πŸ”—

@@ -1,4 +1,6 @@
 use anyhow::Result;
+use fs::Fs;
+
 use gpui::PathPromptOptions;
 use gpui::{
     AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
@@ -996,6 +998,7 @@ impl MultiWorkspace {
                 if let Some(neighbor_key) = neighbor_key {
                     return this.find_or_create_local_workspace(
                         neighbor_key.path_list().clone(),
+                        Some(neighbor_key.clone()),
                         &excluded_workspaces,
                         window,
                         cx,
@@ -1133,7 +1136,13 @@ impl MultiWorkspace {
         }
 
         let Some(connection_options) = host else {
-            return self.find_or_create_local_workspace(paths, &[], window, cx);
+            return self.find_or_create_local_workspace(
+                paths,
+                provisional_project_group_key,
+                &[],
+                window,
+                cx,
+            );
         };
 
         let app_state = self.workspace().read(cx).app_state().clone();
@@ -1191,6 +1200,7 @@ impl MultiWorkspace {
     pub fn find_or_create_local_workspace(
         &mut self,
         path_list: PathList,
+        project_group: Option<ProjectGroupKey>,
         excluding: &[Entity<Workspace>],
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -1204,12 +1214,57 @@ impl MultiWorkspace {
         let paths = path_list.paths().to_vec();
         let app_state = self.workspace().read(cx).app_state().clone();
         let requesting_window = window.window_handle().downcast::<MultiWorkspace>();
+        let fs = <dyn Fs>::global(cx);
+        let excluding = excluding.to_vec();
 
         cx.spawn(async move |_this, cx| {
+            let effective_path_list = if let Some(project_group) = project_group {
+                let metadata_tasks: Vec<_> = paths
+                    .iter()
+                    .map(|path| fs.metadata(path.as_path()))
+                    .collect();
+                let metadata_results = futures::future::join_all(metadata_tasks).await;
+                // Only fall back when every path is definitely absent; real
+                // filesystem errors should not be treated as "missing".
+                let all_paths_missing = !paths.is_empty()
+                    && metadata_results
+                        .into_iter()
+                        // Ok(None) means the path is definitely absent
+                        .all(|result| matches!(result, Ok(None)));
+
+                if all_paths_missing {
+                    project_group.path_list().clone()
+                } else {
+                    PathList::new(&paths)
+                }
+            } else {
+                PathList::new(&paths)
+            };
+
+            if let Some(requesting_window) = requesting_window
+                && let Some(workspace) = requesting_window
+                    .update(cx, |multi_workspace, window, cx| {
+                        multi_workspace
+                            .workspace_for_paths_excluding(
+                                &effective_path_list,
+                                None,
+                                &excluding,
+                                cx,
+                            )
+                            .inspect(|workspace| {
+                                multi_workspace.activate(workspace.clone(), window, cx);
+                            })
+                    })
+                    .ok()
+                    .flatten()
+            {
+                return Ok(workspace);
+            }
+
             let result = cx
                 .update(|cx| {
                     Workspace::new_local(
-                        paths,
+                        effective_path_list.paths().to_vec(),
                         app_state,
                         requesting_window,
                         None,
@@ -1755,7 +1810,7 @@ impl MultiWorkspace {
         cx: &mut Context<Self>,
     ) -> Task<Result<Entity<Workspace>>> {
         if self.multi_workspace_enabled(cx) {
-            self.find_or_create_local_workspace(PathList::new(&paths), &[], window, cx)
+            self.find_or_create_local_workspace(PathList::new(&paths), None, &[], window, cx)
         } else {
             let workspace = self.workspace().clone();
             cx.spawn_in(window, async move |_this, cx| {

crates/workspace/src/multi_workspace_tests.rs πŸ”—

@@ -2,7 +2,7 @@ use std::path::PathBuf;
 
 use super::*;
 use client::proto;
-use fs::FakeFs;
+use fs::{FakeFs, Fs};
 use gpui::TestAppContext;
 use project::DisableAiSettings;
 use serde_json::json;
@@ -433,6 +433,7 @@ async fn test_find_or_create_local_workspace_reuses_active_workspace_when_sideba
         .update_in(cx, |mw, window, cx| {
             mw.find_or_create_local_workspace(
                 PathList::new(&[PathBuf::from("/root_a")]),
+                None,
                 &[],
                 window,
                 cx,
@@ -461,6 +462,73 @@ async fn test_find_or_create_local_workspace_reuses_active_workspace_when_sideba
     });
 }
 
+#[gpui::test]
+async fn test_find_or_create_workspace_uses_project_group_key_when_paths_are_missing(
+    cx: &mut TestAppContext,
+) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        "/project",
+        json!({
+            ".git": {},
+            "src": {},
+        }),
+    )
+    .await;
+    cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
+    let project = Project::test(fs.clone(), ["/project".as_ref()], cx).await;
+    project
+        .update(cx, |project, cx| project.git_scans_complete(cx))
+        .await;
+
+    let project_group_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, window, cx));
+
+    let main_workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
+    let main_workspace_id = main_workspace.entity_id();
+
+    let workspace = multi_workspace
+        .update_in(cx, |mw, window, cx| {
+            mw.find_or_create_workspace(
+                PathList::new(&[PathBuf::from("/wt-feature-a")]),
+                None,
+                Some(project_group_key.clone()),
+                |_options, _window, _cx| Task::ready(Ok(None)),
+                window,
+                cx,
+            )
+        })
+        .await
+        .expect("opening a missing linked-worktree path should fall back to the project group key workspace");
+
+    assert_eq!(
+        workspace.entity_id(),
+        main_workspace_id,
+        "missing linked-worktree paths should reuse the main worktree workspace from the project group key"
+    );
+
+    multi_workspace.read_with(cx, |mw, cx| {
+        assert_eq!(
+            mw.workspace().entity_id(),
+            main_workspace_id,
+            "the active workspace should remain the main worktree workspace"
+        );
+        assert_eq!(
+            PathList::new(&mw.workspace().read(cx).root_paths(cx)),
+            project_group_key.path_list().clone(),
+            "the activated workspace should use the project group key path list rather than the missing linked-worktree path"
+        );
+        assert_eq!(
+            mw.workspaces().count(),
+            1,
+            "falling back to the project group key should not create a second workspace"
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sidebar_open(
     cx: &mut TestAppContext,
@@ -492,6 +560,7 @@ async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sideb
         .update_in(cx, |mw, window, cx| {
             mw.find_or_create_local_workspace(
                 PathList::new(&[PathBuf::from("/root_a")]),
+                None,
                 &[],
                 window,
                 cx,

crates/workspace/src/persistence.rs πŸ”—

@@ -5048,7 +5048,7 @@ mod tests {
             mw.remove(
                 vec![workspace_a.clone()],
                 move |this, window, cx| {
-                    this.find_or_create_local_workspace(path_list, &excluded, window, cx)
+                    this.find_or_create_local_workspace(path_list, None, &excluded, window, cx)
                 },
                 window,
                 cx,

crates/workspace/src/workspace.rs πŸ”—

@@ -9875,11 +9875,13 @@ async fn open_remote_project_inner(
 
     for path in paths {
         let result = cx
-            .update(|cx| Workspace::project_path_for_path(project.clone(), &path, true, cx))
+            .update(|cx| {
+                Workspace::project_path_for_path(project.clone(), path.as_path(), true, cx)
+            })
             .await;
         match result {
             Ok((_, project_path)) => {
-                project_paths_to_open.push((path.clone(), Some(project_path)));
+                project_paths_to_open.push((path, Some(project_path)));
             }
             Err(error) => {
                 project_path_errors.push(error);