workspace: Handle unsaved scratch buffers when opening recent projects (#51611)

Matt Van Horn , Matt Van Horn , and Max Brunsfeld created

Fixes #51456

## Problem

When opening a recent project with an unsaved scratch buffer (untitled
file), the project switch silently fails. The log shows:

```
failed to save contents of buffer
Caused by: FOREIGN KEY constraint failed
```

## Root Cause

When a user creates a new file from the welcome screen and edits it
without saving, the buffer is dirty but has no file path. When opening a
recent project, `prepare_to_close` calls
`save_all_internal(SaveIntent::Close)`, which tries to serialize dirty
items to the database for hot-exit functionality.

The serialization fails with a FOREIGN KEY constraint error because
workspace serialization is throttled - the workspace row may not exist
in the database yet when the editor tries to INSERT into the editors
table referencing that workspace_id.

The previous code used `try_join_all` on all serialize tasks, so a
single serialization failure would abort the entire close flow,
preventing the project switch.

## Fix

Replace `try_join_all` with individual task awaiting. If a serialize
task fails, the item is moved back to the `remaining_dirty_items` list
so the user gets a proper save/discard prompt instead of the action
silently failing.

This is a minimal change (7 insertions, 2 deletions) that:
- Handles the FOREIGN KEY error gracefully
- Preserves the save/discard prompt UX for items that fail serialization
- Logs the serialization error for debugging via `.log_err()`
- Does not change behavior for items that serialize successfully

## Test Plan

- [ ] Open Zed with no active workspace
- [ ] Create a new file from the welcome screen
- [ ] Edit the buffer to make it dirty (do not save)
- [ ] Open a recent project via `projects: open recent`
- [ ] Verify the project opens (previously it silently failed)
- [ ] Verify a save/discard prompt appears for the dirty scratch buffer

Release Notes:

- Fixed opening a recent project silently failing when an unsaved
scratch buffer is present (#51456).

---

This PR was written with the assistance of AI (Claude).

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>

Change summary

crates/workspace/src/workspace.rs | 77 ++++++++++++++++++++++++++------
1 file changed, 61 insertions(+), 16 deletions(-)

Detailed changes

crates/workspace/src/workspace.rs 🔗

@@ -3444,24 +3444,26 @@ impl Workspace {
         let project = self.project.clone();
         cx.spawn_in(window, async move |workspace, cx| {
             let dirty_items = if save_intent == SaveIntent::Close && !dirty_items.is_empty() {
-                let (serialize_tasks, remaining_dirty_items) =
-                    workspace.update_in(cx, |workspace, window, cx| {
-                        let mut remaining_dirty_items = Vec::new();
-                        let mut serialize_tasks = Vec::new();
-                        for (pane, item) in dirty_items {
-                            if let Some(task) = item
-                                .to_serializable_item_handle(cx)
-                                .and_then(|handle| handle.serialize(workspace, true, window, cx))
-                            {
-                                serialize_tasks.push(task);
-                            } else {
-                                remaining_dirty_items.push((pane, item));
-                            }
+                let mut serialize_tasks = Vec::new();
+                let mut remaining_dirty_items = Vec::new();
+                workspace.update_in(cx, |workspace, window, cx| {
+                    for (pane, item) in dirty_items {
+                        if let Some(task) = item
+                            .to_serializable_item_handle(cx)
+                            .and_then(|handle| handle.serialize(workspace, true, window, cx))
+                        {
+                            serialize_tasks.push((pane, item, task));
+                        } else {
+                            remaining_dirty_items.push((pane, item));
                         }
-                        (serialize_tasks, remaining_dirty_items)
-                    })?;
+                    }
+                })?;
 
-                futures::future::try_join_all(serialize_tasks).await?;
+                for (pane, item, task) in serialize_tasks {
+                    if task.await.log_err().is_none() {
+                        remaining_dirty_items.push((pane, item));
+                    }
+                }
 
                 if !remaining_dirty_items.is_empty() {
                     workspace.update(cx, |_, cx| cx.emit(Event::Activate))?;
@@ -11300,6 +11302,49 @@ mod tests {
         assert!(task.await.unwrap());
     }
 
+    #[gpui::test]
+    async fn test_close_window_with_failing_serialization(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        cx.update(|cx| {
+            register_serializable_item::<TestItem>(cx);
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, None, cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+        let item = cx.new(|cx| {
+            TestItem::new(cx).with_dirty(true).with_serialize(|| {
+                Some(Task::ready(Err(anyhow::anyhow!(
+                    "FOREIGN KEY constraint failed"
+                ))))
+            })
+        });
+        workspace.update_in(cx, |w, window, cx| {
+            w.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx);
+        });
+
+        let task = workspace.update_in(cx, |w, window, cx| {
+            w.prepare_to_close(CloseIntent::CloseWindow, window, cx)
+        });
+        cx.executor().run_until_parked();
+
+        // The failing serialization must not short-circuit the close; a
+        // save/discard prompt must be shown for the dirty scratch item.
+        assert!(
+            cx.has_pending_prompt(),
+            "a save/discard prompt should be shown for the dirty scratch item \
+             when its serialization fails"
+        );
+        cx.simulate_prompt_answer("Don't Save");
+        cx.executor().run_until_parked();
+
+        // Preparing to close succeeds, even though serialization failed.
+        assert!(task.await.unwrap());
+    }
+
     #[gpui::test]
     async fn test_close_pane_items(cx: &mut TestAppContext) {
         init_test(cx);