From eed33ab2837274997424c6e3e7fa61e15748494e Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:17:29 -0400 Subject: [PATCH] sidebar: Fallback to main git worktree path when opening thread in deleted git worktree (#53899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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 --- 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(-) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index f573250dbe98cf15adfbbf13d78a4d8e2ab0fb07..b4dd12f8ea4a875a27bd2386ab79e35c3481d157 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/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::() { 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, diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 42a0df3e0cc0a4f833588cd97f0917706c860f0a..2f263d260f2c829653a342dab219865d869d1da0 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/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( diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 30350a0943bb6cf1f1deb625d5529666766da811..5b2d170def2501d46482258f07d32e5cb40ee8a5 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/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, excluding: &[Entity], window: &mut Window, cx: &mut Context, @@ -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::(); + let 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, ) -> Task>> { 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| { diff --git a/crates/workspace/src/multi_workspace_tests.rs b/crates/workspace/src/multi_workspace_tests.rs index fda616be2b10635a18057c6af2fb87e578c9ccc9..16b2b43c488017cafcbf021549d06f0a8311dac4 100644 --- a/crates/workspace/src/multi_workspace_tests.rs +++ b/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| ::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, diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 48e94f2689b43d993d0ca9fc83e8dab9edd9c73d..f0f44109320a7bba0af0f9adffe9f5919f4e02b6 100644 --- a/crates/workspace/src/persistence.rs +++ b/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, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b84ec0f176fb44616e88b6b8cca5170d9b65dc84..eb49ba6713fa5a4a67d29724a2d8f9c4378030af 100644 --- a/crates/workspace/src/workspace.rs +++ b/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);