Make deserialization a bit more resilient to data changes (#53362)

Mikayla Maki created

This PR makes sidebar deserialization enforce the invariants that the
multiworkspace is supposed to enforce. Also, this PR makes it so that
failing to deserialize the active workspace no longer totally fails to
deserialize the multiworkspace.

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

Release Notes:

- N/A

Change summary

crates/project/src/project.rs           |  1 
crates/workspace/src/multi_workspace.rs |  7 +
crates/workspace/src/workspace.rs       | 96 +++++++++++++++++++++-----
crates/worktree/src/worktree.rs         |  2 
4 files changed, 85 insertions(+), 21 deletions(-)

Detailed changes

crates/project/src/project.rs 🔗

@@ -143,6 +143,7 @@ use worktree::{CreatedEntry, Snapshot, Traversal};
 pub use worktree::{
     Entry, EntryKind, FS_WATCH_LATENCY, File, LocalWorktree, PathChange, ProjectEntryId,
     UpdatedEntriesSet, UpdatedGitRepositoriesSet, Worktree, WorktreeId, WorktreeSettings,
+    discover_root_repo_common_dir,
 };
 use worktree_store::{WorktreeStore, WorktreeStoreEvent};
 

crates/workspace/src/multi_workspace.rs 🔗

@@ -605,7 +605,12 @@ impl MultiWorkspace {
     }
 
     pub fn restore_project_group_keys(&mut self, keys: Vec<ProjectGroupKey>) {
-        let mut restored = keys;
+        let mut restored: Vec<ProjectGroupKey> = Vec::with_capacity(keys.len());
+        for key in keys {
+            if !restored.contains(&key) {
+                restored.push(key);
+            }
+        }
         for existing_key in &self.project_group_keys {
             if !restored.contains(existing_key) {
                 restored.push(existing_key.clone());

crates/workspace/src/workspace.rs 🔗

@@ -8712,34 +8712,92 @@ pub async fn restore_multiworkspace(
         ..
     } = state;
 
-    let window_handle = if active_workspace.paths.is_empty() {
+    let workspace_result = if active_workspace.paths.is_empty() {
         cx.update(|cx| {
             open_workspace_by_id(active_workspace.workspace_id, app_state.clone(), None, cx)
         })
-        .await?
+        .await
     } else {
-        let OpenResult { window, .. } = cx
-            .update(|cx| {
-                Workspace::new_local(
-                    active_workspace.paths.paths().to_vec(),
-                    app_state.clone(),
-                    None,
-                    None,
-                    None,
-                    OpenMode::Activate,
-                    cx,
-                )
-            })
-            .await?;
-        window
+        cx.update(|cx| {
+            Workspace::new_local(
+                active_workspace.paths.paths().to_vec(),
+                app_state.clone(),
+                None,
+                None,
+                None,
+                OpenMode::Activate,
+                cx,
+            )
+        })
+        .await
+        .map(|result| result.window)
+    };
+
+    let window_handle = match workspace_result {
+        Ok(handle) => handle,
+        Err(err) => {
+            log::error!("Failed to restore active workspace: {err:#}");
+
+            // Try each project group's paths as a fallback.
+            let mut fallback_handle = None;
+            for key in &project_group_keys {
+                let key: ProjectGroupKey = key.clone().into();
+                let paths = key.path_list().paths().to_vec();
+                match cx
+                    .update(|cx| {
+                        Workspace::new_local(
+                            paths,
+                            app_state.clone(),
+                            None,
+                            None,
+                            None,
+                            OpenMode::Activate,
+                            cx,
+                        )
+                    })
+                    .await
+                {
+                    Ok(OpenResult { window, .. }) => {
+                        fallback_handle = Some(window);
+                        break;
+                    }
+                    Err(fallback_err) => {
+                        log::error!("Fallback project group also failed: {fallback_err:#}");
+                    }
+                }
+            }
+
+            fallback_handle.ok_or(err)?
+        }
     };
 
     if !project_group_keys.is_empty() {
-        let restored_keys: Vec<ProjectGroupKey> =
-            project_group_keys.into_iter().map(Into::into).collect();
+        let fs = app_state.fs.clone();
+
+        // Resolve linked worktree paths to their main repo paths so
+        // stale keys from previous sessions get normalized and deduped.
+        let mut resolved_keys: Vec<ProjectGroupKey> = Vec::new();
+        for key in project_group_keys.into_iter().map(ProjectGroupKey::from) {
+            let mut resolved_paths = Vec::new();
+            for path in key.path_list().paths() {
+                if let Some(common_dir) =
+                    project::discover_root_repo_common_dir(path, fs.as_ref()).await
+                {
+                    let main_path = common_dir.parent().unwrap_or(&common_dir);
+                    resolved_paths.push(main_path.to_path_buf());
+                } else {
+                    resolved_paths.push(path.to_path_buf());
+                }
+            }
+            let resolved = ProjectGroupKey::new(key.host(), PathList::new(&resolved_paths));
+            if !resolved_keys.contains(&resolved) {
+                resolved_keys.push(resolved);
+            }
+        }
+
         window_handle
             .update(cx, |multi_workspace, _window, _cx| {
-                multi_workspace.restore_project_group_keys(restored_keys);
+                multi_workspace.restore_project_group_keys(resolved_keys);
             })
             .ok();
     }

crates/worktree/src/worktree.rs 🔗

@@ -6109,7 +6109,7 @@ fn parse_gitfile(content: &str) -> anyhow::Result<&Path> {
     Ok(Path::new(path.trim()))
 }
 
-async fn discover_root_repo_common_dir(root_abs_path: &Path, fs: &dyn Fs) -> Option<Arc<Path>> {
+pub async fn discover_root_repo_common_dir(root_abs_path: &Path, fs: &dyn Fs) -> Option<Arc<Path>> {
     let root_dot_git = root_abs_path.join(DOT_GIT);
     if !fs.metadata(&root_dot_git).await.is_ok_and(|m| m.is_some()) {
         return None;