Merge pull request #1291 from zed-industries/fix-autosave-on-close

Max Brunsfeld created

Fix autosave when closing a tab

Change summary

crates/diagnostics/src/diagnostics.rs |   9 
crates/editor/src/editor.rs           | 131 --------------------
crates/editor/src/items.rs            |   4 
crates/gpui/src/app.rs                | 126 ++++++++++++++------
crates/search/src/project_search.rs   |  20 ++
crates/workspace/src/pane.rs          |  27 ++++
crates/workspace/src/workspace.rs     | 178 ++++++++++++++++++++++++++++
7 files changed, 316 insertions(+), 179 deletions(-)

Detailed changes

crates/diagnostics/src/diagnostics.rs 🔗

@@ -568,10 +568,11 @@ impl workspace::Item for ProjectDiagnosticsEditor {
     }
 
     fn should_update_tab_on_event(event: &Event) -> bool {
-        matches!(
-            event,
-            Event::Saved | Event::DirtyChanged | Event::TitleChanged
-        )
+        Editor::should_update_tab_on_event(event)
+    }
+
+    fn is_edit_event(event: &Self::Event) -> bool {
+        Editor::is_edit_event(event)
     }
 
     fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {

crates/editor/src/editor.rs 🔗

@@ -18,7 +18,6 @@ use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque};
 pub use display_map::DisplayPoint;
 use display_map::*;
 pub use element::*;
-use futures::{channel::oneshot, FutureExt};
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     actions,
@@ -51,7 +50,7 @@ use ordered_float::OrderedFloat;
 use project::{LocationLink, Project, ProjectPath, ProjectTransaction};
 use selections_collection::{resolve_multiple, MutableSelectionsCollection, SelectionsCollection};
 use serde::{Deserialize, Serialize};
-use settings::{Autosave, Settings};
+use settings::Settings;
 use smallvec::SmallVec;
 use smol::Timer;
 use snippet::Snippet;
@@ -439,8 +438,6 @@ pub struct Editor {
     leader_replica_id: Option<u16>,
     hover_state: HoverState,
     link_go_to_definition_state: LinkGoToDefinitionState,
-    pending_autosave: Option<Task<Option<()>>>,
-    cancel_pending_autosave: Option<oneshot::Sender<()>>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -1028,13 +1025,10 @@ impl Editor {
             leader_replica_id: None,
             hover_state: Default::default(),
             link_go_to_definition_state: Default::default(),
-            pending_autosave: Default::default(),
-            cancel_pending_autosave: Default::default(),
             _subscriptions: vec![
                 cx.observe(&buffer, Self::on_buffer_changed),
                 cx.subscribe(&buffer, Self::on_buffer_event),
                 cx.observe(&display_map, Self::on_display_map_changed),
-                cx.observe_window_activation(Self::on_window_activation_changed),
             ],
         };
         this.end_selection(cx);
@@ -5584,33 +5578,6 @@ impl Editor {
                 self.refresh_active_diagnostics(cx);
                 self.refresh_code_actions(cx);
                 cx.emit(Event::BufferEdited);
-                if let Autosave::AfterDelay { milliseconds } = cx.global::<Settings>().autosave {
-                    let pending_autosave =
-                        self.pending_autosave.take().unwrap_or(Task::ready(None));
-                    if let Some(cancel_pending_autosave) = self.cancel_pending_autosave.take() {
-                        let _ = cancel_pending_autosave.send(());
-                    }
-
-                    let (cancel_tx, mut cancel_rx) = oneshot::channel();
-                    self.cancel_pending_autosave = Some(cancel_tx);
-                    self.pending_autosave = Some(cx.spawn_weak(|this, mut cx| async move {
-                        let mut timer = cx
-                            .background()
-                            .timer(Duration::from_millis(milliseconds))
-                            .fuse();
-                        pending_autosave.await;
-                        futures::select_biased! {
-                            _ = cancel_rx => return None,
-                            _ = timer => {}
-                        }
-
-                        this.upgrade(&cx)?
-                            .update(&mut cx, |this, cx| this.autosave(cx))
-                            .await
-                            .log_err();
-                        None
-                    }));
-                }
             }
             language::Event::Reparsed => cx.emit(Event::Reparsed),
             language::Event::DirtyChanged => cx.emit(Event::DirtyChanged),
@@ -5629,25 +5596,6 @@ impl Editor {
         cx.notify();
     }
 
-    fn on_window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
-        if !active && cx.global::<Settings>().autosave == Autosave::OnWindowChange {
-            self.autosave(cx).detach_and_log_err(cx);
-        }
-    }
-
-    fn autosave(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
-        if let Some(project) = self.project.clone() {
-            if self.buffer.read(cx).is_dirty(cx)
-                && !self.buffer.read(cx).has_conflict(cx)
-                && workspace::Item::can_save(self, cx)
-            {
-                return workspace::Item::save(self, project, cx);
-            }
-        }
-
-        Task::ready(Ok(()))
-    }
-
     pub fn set_searchable(&mut self, searchable: bool) {
         self.searchable = searchable;
     }
@@ -5865,10 +5813,6 @@ impl View for Editor {
         hide_hover(self, cx);
         cx.emit(Event::Blurred);
         cx.notify();
-
-        if cx.global::<Settings>().autosave == Autosave::OnFocusChange {
-            self.autosave(cx).detach_and_log_err(cx);
-        }
     }
 
     fn keymap_context(&self, _: &AppContext) -> gpui::keymap::Context {
@@ -6282,23 +6226,22 @@ mod tests {
     use super::*;
     use futures::StreamExt;
     use gpui::{
-        executor::Deterministic,
         geometry::rect::RectF,
         platform::{WindowBounds, WindowOptions},
     };
     use indoc::indoc;
     use language::{FakeLspAdapter, LanguageConfig};
     use lsp::FakeLanguageServer;
-    use project::{FakeFs, Fs};
+    use project::FakeFs;
     use settings::LanguageSettings;
-    use std::{cell::RefCell, path::Path, rc::Rc, time::Instant};
+    use std::{cell::RefCell, rc::Rc, time::Instant};
     use text::Point;
     use unindent::Unindent;
     use util::{
         assert_set_eq,
         test::{marked_text_by, marked_text_ranges, marked_text_ranges_by, sample_text},
     };
-    use workspace::{FollowableItem, Item, ItemHandle};
+    use workspace::{FollowableItem, ItemHandle};
 
     #[gpui::test]
     fn test_edit_events(cx: &mut MutableAppContext) {
@@ -9562,72 +9505,6 @@ mod tests {
         save.await.unwrap();
     }
 
-    #[gpui::test]
-    async fn test_autosave(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
-        deterministic.forbid_parking();
-
-        let fs = FakeFs::new(cx.background().clone());
-        fs.insert_file("/file.rs", Default::default()).await;
-
-        let project = Project::test(fs.clone(), ["/file.rs".as_ref()], cx).await;
-        let buffer = project
-            .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
-            .await
-            .unwrap();
-
-        let (_, editor) = cx.add_window(|cx| Editor::for_buffer(buffer, Some(project), cx));
-
-        // Autosave on window change.
-        editor.update(cx, |editor, cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.autosave = Autosave::OnWindowChange;
-            });
-            editor.insert("X", cx);
-            assert!(editor.is_dirty(cx))
-        });
-
-        // Deactivating the window saves the file.
-        cx.simulate_window_activation(None);
-        deterministic.run_until_parked();
-        assert_eq!(fs.load(Path::new("/file.rs")).await.unwrap(), "X");
-        editor.read_with(cx, |editor, cx| assert!(!editor.is_dirty(cx)));
-
-        // Autosave on focus change.
-        editor.update(cx, |editor, cx| {
-            cx.focus_self();
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.autosave = Autosave::OnFocusChange;
-            });
-            editor.insert("X", cx);
-            assert!(editor.is_dirty(cx))
-        });
-
-        // Blurring the editor saves the file.
-        editor.update(cx, |_, cx| cx.blur());
-        deterministic.run_until_parked();
-        assert_eq!(fs.load(Path::new("/file.rs")).await.unwrap(), "XX");
-        editor.read_with(cx, |editor, cx| assert!(!editor.is_dirty(cx)));
-
-        // Autosave after delay.
-        editor.update(cx, |editor, cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.autosave = Autosave::AfterDelay { milliseconds: 500 };
-            });
-            editor.insert("X", cx);
-            assert!(editor.is_dirty(cx))
-        });
-
-        // Delay hasn't fully expired, so the file is still dirty and unsaved.
-        deterministic.advance_clock(Duration::from_millis(250));
-        assert_eq!(fs.load(Path::new("/file.rs")).await.unwrap(), "XX");
-        editor.read_with(cx, |editor, cx| assert!(editor.is_dirty(cx)));
-
-        // After delay expires, the file is saved.
-        deterministic.advance_clock(Duration::from_millis(250));
-        assert_eq!(fs.load(Path::new("/file.rs")).await.unwrap(), "XXX");
-        editor.read_with(cx, |editor, cx| assert!(!editor.is_dirty(cx)));
-    }
-
     #[gpui::test]
     async fn test_completion(cx: &mut gpui::TestAppContext) {
         let mut language = Language::new(

crates/editor/src/items.rs 🔗

@@ -445,6 +445,10 @@ impl Item for Editor {
             Event::Saved | Event::DirtyChanged | Event::TitleChanged
         )
     }
+
+    fn is_edit_event(event: &Self::Event) -> bool {
+        matches!(event, Event::BufferEdited)
+    }
 }
 
 impl ProjectItem for Editor {

crates/gpui/src/app.rs 🔗

@@ -811,7 +811,7 @@ type GlobalActionCallback = dyn FnMut(&dyn Action, &mut MutableAppContext);
 type SubscriptionCallback = Box<dyn FnMut(&dyn Any, &mut MutableAppContext) -> bool>;
 type GlobalSubscriptionCallback = Box<dyn FnMut(&dyn Any, &mut MutableAppContext)>;
 type ObservationCallback = Box<dyn FnMut(&mut MutableAppContext) -> bool>;
-type FocusObservationCallback = Box<dyn FnMut(&mut MutableAppContext) -> bool>;
+type FocusObservationCallback = Box<dyn FnMut(bool, &mut MutableAppContext) -> bool>;
 type GlobalObservationCallback = Box<dyn FnMut(&mut MutableAppContext)>;
 type ReleaseObservationCallback = Box<dyn FnOnce(&dyn Any, &mut MutableAppContext)>;
 type ActionObservationCallback = Box<dyn FnMut(TypeId, &mut MutableAppContext)>;
@@ -1305,7 +1305,7 @@ impl MutableAppContext {
 
     fn observe_focus<F, V>(&mut self, handle: &ViewHandle<V>, mut callback: F) -> Subscription
     where
-        F: 'static + FnMut(ViewHandle<V>, &mut MutableAppContext) -> bool,
+        F: 'static + FnMut(ViewHandle<V>, bool, &mut MutableAppContext) -> bool,
         V: View,
     {
         let subscription_id = post_inc(&mut self.next_subscription_id);
@@ -1314,9 +1314,9 @@ impl MutableAppContext {
         self.pending_effects.push_back(Effect::FocusObservation {
             view_id,
             subscription_id,
-            callback: Box::new(move |cx| {
+            callback: Box::new(move |focused, cx| {
                 if let Some(observed) = observed.upgrade(cx) {
-                    callback(observed, cx)
+                    callback(observed, focused, cx)
                 } else {
                     false
                 }
@@ -2525,6 +2525,31 @@ impl MutableAppContext {
                 if let Some(mut blurred_view) = this.cx.views.remove(&(window_id, blurred_id)) {
                     blurred_view.on_blur(this, window_id, blurred_id);
                     this.cx.views.insert((window_id, blurred_id), blurred_view);
+
+                    let callbacks = this.focus_observations.lock().remove(&blurred_id);
+                    if let Some(callbacks) = callbacks {
+                        for (id, callback) in callbacks {
+                            if let Some(mut callback) = callback {
+                                let alive = callback(false, this);
+                                if alive {
+                                    match this
+                                        .focus_observations
+                                        .lock()
+                                        .entry(blurred_id)
+                                        .or_default()
+                                        .entry(id)
+                                    {
+                                        btree_map::Entry::Vacant(entry) => {
+                                            entry.insert(Some(callback));
+                                        }
+                                        btree_map::Entry::Occupied(entry) => {
+                                            entry.remove();
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
                 }
             }
 
@@ -2537,7 +2562,7 @@ impl MutableAppContext {
                     if let Some(callbacks) = callbacks {
                         for (id, callback) in callbacks {
                             if let Some(mut callback) = callback {
-                                let alive = callback(this);
+                                let alive = callback(true, this);
                                 if alive {
                                     match this
                                         .focus_observations
@@ -3598,20 +3623,21 @@ impl<'a, T: View> ViewContext<'a, T> {
 
     pub fn observe_focus<F, V>(&mut self, handle: &ViewHandle<V>, mut callback: F) -> Subscription
     where
-        F: 'static + FnMut(&mut T, ViewHandle<V>, &mut ViewContext<T>),
+        F: 'static + FnMut(&mut T, ViewHandle<V>, bool, &mut ViewContext<T>),
         V: View,
     {
         let observer = self.weak_handle();
-        self.app.observe_focus(handle, move |observed, cx| {
-            if let Some(observer) = observer.upgrade(cx) {
-                observer.update(cx, |observer, cx| {
-                    callback(observer, observed, cx);
-                });
-                true
-            } else {
-                false
-            }
-        })
+        self.app
+            .observe_focus(handle, move |observed, focused, cx| {
+                if let Some(observer) = observer.upgrade(cx) {
+                    observer.update(cx, |observer, cx| {
+                        callback(observer, observed, focused, cx);
+                    });
+                    true
+                } else {
+                    false
+                }
+            })
     }
 
     pub fn observe_release<E, F, H>(&mut self, handle: &H, mut callback: F) -> Subscription
@@ -6448,11 +6474,13 @@ mod tests {
         view_1.update(cx, |_, cx| {
             cx.observe_focus(&view_2, {
                 let observed_events = observed_events.clone();
-                move |this, view, cx| {
+                move |this, view, focused, cx| {
+                    let label = if focused { "focus" } else { "blur" };
                     observed_events.lock().push(format!(
-                        "{} observed {}'s focus",
+                        "{} observed {}'s {}",
                         this.name,
-                        view.read(cx).name
+                        view.read(cx).name,
+                        label
                     ))
                 }
             })
@@ -6461,16 +6489,20 @@ mod tests {
         view_2.update(cx, |_, cx| {
             cx.observe_focus(&view_1, {
                 let observed_events = observed_events.clone();
-                move |this, view, cx| {
+                move |this, view, focused, cx| {
+                    let label = if focused { "focus" } else { "blur" };
                     observed_events.lock().push(format!(
-                        "{} observed {}'s focus",
+                        "{} observed {}'s {}",
                         this.name,
-                        view.read(cx).name
+                        view.read(cx).name,
+                        label
                     ))
                 }
             })
             .detach();
         });
+        assert_eq!(mem::take(&mut *view_events.lock()), ["view 1 focused"]);
+        assert_eq!(mem::take(&mut *observed_events.lock()), Vec::<&str>::new());
 
         view_1.update(cx, |_, cx| {
             // Ensure only the latest focus is honored.
@@ -6478,31 +6510,47 @@ mod tests {
             cx.focus(&view_1);
             cx.focus(&view_2);
         });
-        view_1.update(cx, |_, cx| cx.focus(&view_1));
-        view_1.update(cx, |_, cx| cx.focus(&view_2));
-        view_1.update(cx, |_, _| drop(view_2));
+        assert_eq!(
+            mem::take(&mut *view_events.lock()),
+            ["view 1 blurred", "view 2 focused"],
+        );
+        assert_eq!(
+            mem::take(&mut *observed_events.lock()),
+            [
+                "view 2 observed view 1's blur",
+                "view 1 observed view 2's focus"
+            ]
+        );
 
+        view_1.update(cx, |_, cx| cx.focus(&view_1));
+        assert_eq!(
+            mem::take(&mut *view_events.lock()),
+            ["view 2 blurred", "view 1 focused"],
+        );
         assert_eq!(
-            *view_events.lock(),
+            mem::take(&mut *observed_events.lock()),
             [
-                "view 1 focused".to_string(),
-                "view 1 blurred".to_string(),
-                "view 2 focused".to_string(),
-                "view 2 blurred".to_string(),
-                "view 1 focused".to_string(),
-                "view 1 blurred".to_string(),
-                "view 2 focused".to_string(),
-                "view 1 focused".to_string(),
-            ],
+                "view 1 observed view 2's blur",
+                "view 2 observed view 1's focus"
+            ]
         );
+
+        view_1.update(cx, |_, cx| cx.focus(&view_2));
         assert_eq!(
-            *observed_events.lock(),
+            mem::take(&mut *view_events.lock()),
+            ["view 1 blurred", "view 2 focused"],
+        );
+        assert_eq!(
+            mem::take(&mut *observed_events.lock()),
             [
-                "view 1 observed view 2's focus".to_string(),
-                "view 2 observed view 1's focus".to_string(),
-                "view 1 observed view 2's focus".to_string(),
+                "view 2 observed view 1's blur",
+                "view 1 observed view 2's focus"
             ]
         );
+
+        view_1.update(cx, |_, _| drop(view_2));
+        assert_eq!(mem::take(&mut *view_events.lock()), ["view 1 focused"]);
+        assert_eq!(mem::take(&mut *observed_events.lock()), Vec::<&str>::new());
     }
 
     #[crate::test(self)]

crates/search/src/project_search.rs 🔗

@@ -329,6 +329,14 @@ impl Item for ProjectSearchView {
     fn should_update_tab_on_event(event: &ViewEvent) -> bool {
         matches!(event, ViewEvent::UpdateTab)
     }
+
+    fn is_edit_event(event: &Self::Event) -> bool {
+        if let ViewEvent::EditorEvent(editor_event) = event {
+            Editor::is_edit_event(editor_event)
+        } else {
+            false
+        }
+    }
 }
 
 impl ProjectSearchView {
@@ -365,8 +373,10 @@ impl ProjectSearchView {
             cx.emit(ViewEvent::EditorEvent(event.clone()))
         })
         .detach();
-        cx.observe_focus(&query_editor, |this, _, _| {
-            this.results_editor_was_focused = false;
+        cx.observe_focus(&query_editor, |this, _, focused, _| {
+            if focused {
+                this.results_editor_was_focused = false;
+            }
         })
         .detach();
 
@@ -377,8 +387,10 @@ impl ProjectSearchView {
         });
         cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
             .detach();
-        cx.observe_focus(&results_editor, |this, _, _| {
-            this.results_editor_was_focused = true;
+        cx.observe_focus(&results_editor, |this, _, focused, _| {
+            if focused {
+                this.results_editor_was_focused = true;
+            }
         })
         .detach();
         cx.subscribe(&results_editor, |this, _, event, cx| {

crates/workspace/src/pane.rs 🔗

@@ -14,7 +14,7 @@ use gpui::{
 };
 use project::{Project, ProjectEntryId, ProjectPath};
 use serde::Deserialize;
-use settings::Settings;
+use settings::{Autosave, Settings};
 use std::{any::Any, cell::RefCell, mem, path::Path, rc::Rc};
 use util::ResultExt;
 
@@ -677,7 +677,13 @@ impl Pane {
                 _ => return Ok(false),
             }
         } else if is_dirty && (can_save || is_singleton) {
-            let should_save = if should_prompt_for_save {
+            let will_autosave = cx.read(|cx| {
+                matches!(
+                    cx.global::<Settings>().autosave,
+                    Autosave::OnFocusChange | Autosave::OnWindowChange
+                ) && Self::can_autosave_item(item.as_ref(), cx)
+            });
+            let should_save = if should_prompt_for_save && !will_autosave {
                 let mut answer = pane.update(cx, |pane, cx| {
                     pane.activate_item(item_ix, true, true, cx);
                     cx.prompt(
@@ -718,6 +724,23 @@ impl Pane {
         Ok(true)
     }
 
+    fn can_autosave_item(item: &dyn ItemHandle, cx: &AppContext) -> bool {
+        let is_deleted = item.project_entry_ids(cx).is_empty();
+        item.is_dirty(cx) && !item.has_conflict(cx) && item.can_save(cx) && !is_deleted
+    }
+
+    pub fn autosave_item(
+        item: &dyn ItemHandle,
+        project: ModelHandle<Project>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<()>> {
+        if Self::can_autosave_item(item, cx) {
+            item.save(project, cx)
+        } else {
+            Task::ready(Ok(()))
+        }
+    }
+
     pub fn focus_active_item(&mut self, cx: &mut ViewContext<Self>) {
         if let Some(active_item) = self.active_item() {
             cx.focus(active_item);

crates/workspace/src/workspace.rs 🔗

@@ -11,6 +11,7 @@ use client::{
 };
 use clock::ReplicaId;
 use collections::{hash_map, HashMap, HashSet};
+use futures::{channel::oneshot, FutureExt};
 use gpui::{
     actions,
     color::Color,
@@ -30,7 +31,7 @@ pub use pane_group::*;
 use postage::prelude::Stream;
 use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, ProjectStore, Worktree, WorktreeId};
 use serde::Deserialize;
-use settings::Settings;
+use settings::{Autosave, Settings};
 use sidebar::{Side, Sidebar, SidebarButtons, ToggleSidebarItem};
 use smallvec::SmallVec;
 use status_bar::StatusBar;
@@ -41,12 +42,14 @@ use std::{
     cell::RefCell,
     fmt,
     future::Future,
+    mem,
     path::{Path, PathBuf},
     rc::Rc,
     sync::{
         atomic::{AtomicBool, Ordering::SeqCst},
         Arc,
     },
+    time::Duration,
 };
 use theme::{Theme, ThemeRegistry};
 pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
@@ -296,6 +299,9 @@ pub trait Item: View {
     fn should_update_tab_on_event(_: &Self::Event) -> bool {
         false
     }
+    fn is_edit_event(_: &Self::Event) -> bool {
+        false
+    }
     fn act_as_type(
         &self,
         type_id: TypeId,
@@ -510,6 +516,8 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
             }
         }
 
+        let mut pending_autosave = None;
+        let mut cancel_pending_autosave = oneshot::channel::<()>().0;
         let pending_update = Rc::new(RefCell::new(None));
         let pending_update_scheduled = Rc::new(AtomicBool::new(false));
         let pane = pane.downgrade();
@@ -570,6 +578,40 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
                     cx.notify();
                 });
             }
+
+            if T::is_edit_event(event) {
+                if let Autosave::AfterDelay { milliseconds } = cx.global::<Settings>().autosave {
+                    let prev_autosave = pending_autosave.take().unwrap_or(Task::ready(Some(())));
+                    let (cancel_tx, mut cancel_rx) = oneshot::channel::<()>();
+                    let prev_cancel_tx = mem::replace(&mut cancel_pending_autosave, cancel_tx);
+                    let project = workspace.project.downgrade();
+                    let _ = prev_cancel_tx.send(());
+                    pending_autosave = Some(cx.spawn_weak(|_, mut cx| async move {
+                        let mut timer = cx
+                            .background()
+                            .timer(Duration::from_millis(milliseconds))
+                            .fuse();
+                        prev_autosave.await;
+                        futures::select_biased! {
+                            _ = cancel_rx => return None,
+                            _ = timer => {}
+                        }
+
+                        let project = project.upgrade(&cx)?;
+                        cx.update(|cx| Pane::autosave_item(&item, project, cx))
+                            .await
+                            .log_err();
+                        None
+                    }));
+                }
+            }
+        })
+        .detach();
+
+        cx.observe_focus(self, move |workspace, item, focused, cx| {
+            if !focused && cx.global::<Settings>().autosave == Autosave::OnFocusChange {
+                Pane::autosave_item(&item, workspace.project.clone(), cx).detach_and_log_err(cx);
+            }
         })
         .detach();
     }
@@ -774,6 +816,8 @@ impl Workspace {
             cx.notify()
         })
         .detach();
+        cx.observe_window_activation(Self::on_window_activation_changed)
+            .detach();
 
         cx.subscribe(&project, move |this, project, event, cx| {
             match event {
@@ -2314,6 +2358,19 @@ impl Workspace {
         }
         None
     }
+
+    fn on_window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+        if !active && cx.global::<Settings>().autosave == Autosave::OnWindowChange {
+            for pane in &self.panes {
+                pane.update(cx, |pane, cx| {
+                    for item in pane.items() {
+                        Pane::autosave_item(item.as_ref(), self.project.clone(), cx)
+                            .detach_and_log_err(cx);
+                    }
+                });
+            }
+        }
+    }
 }
 
 impl Entity for Workspace {
@@ -2631,7 +2688,7 @@ fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use gpui::{ModelHandle, TestAppContext, ViewContext};
+    use gpui::{executor::Deterministic, ModelHandle, TestAppContext, ViewContext};
     use project::{FakeFs, Project, ProjectEntryId};
     use serde_json::json;
 
@@ -2969,6 +3026,110 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_autosave(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
+        deterministic.forbid_parking();
+
+        Settings::test_async(cx);
+        let fs = FakeFs::new(cx.background());
+
+        let project = Project::test(fs, [], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
+
+        let item = cx.add_view(window_id, |_| {
+            let mut item = TestItem::new();
+            item.project_entry_ids = vec![ProjectEntryId::from_proto(1)];
+            item
+        });
+        let item_id = item.id();
+        workspace.update(cx, |workspace, cx| {
+            workspace.add_item(Box::new(item.clone()), cx);
+        });
+
+        // Autosave on window change.
+        item.update(cx, |item, cx| {
+            cx.update_global(|settings: &mut Settings, _| {
+                settings.autosave = Autosave::OnWindowChange;
+            });
+            item.is_dirty = true;
+        });
+
+        // Deactivating the window saves the file.
+        cx.simulate_window_activation(None);
+        deterministic.run_until_parked();
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 1));
+
+        // Autosave on focus change.
+        item.update(cx, |item, cx| {
+            cx.focus_self();
+            cx.update_global(|settings: &mut Settings, _| {
+                settings.autosave = Autosave::OnFocusChange;
+            });
+            item.is_dirty = true;
+        });
+
+        // Blurring the item saves the file.
+        item.update(cx, |_, cx| cx.blur());
+        deterministic.run_until_parked();
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 2));
+
+        // Autosave after delay.
+        item.update(cx, |item, cx| {
+            cx.update_global(|settings: &mut Settings, _| {
+                settings.autosave = Autosave::AfterDelay { milliseconds: 500 };
+            });
+            item.is_dirty = true;
+            cx.emit(TestItemEvent::Edit);
+        });
+
+        // Delay hasn't fully expired, so the file is still dirty and unsaved.
+        deterministic.advance_clock(Duration::from_millis(250));
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 2));
+
+        // After delay expires, the file is saved.
+        deterministic.advance_clock(Duration::from_millis(250));
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 3));
+
+        // Autosave on focus change, ensuring closing the tab counts as such.
+        item.update(cx, |item, cx| {
+            cx.update_global(|settings: &mut Settings, _| {
+                settings.autosave = Autosave::OnFocusChange;
+            });
+            item.is_dirty = true;
+        });
+
+        workspace
+            .update(cx, |workspace, cx| {
+                let pane = workspace.active_pane().clone();
+                Pane::close_items(workspace, pane, cx, move |id| id == item_id)
+            })
+            .await
+            .unwrap();
+        assert!(!cx.has_pending_prompt(window_id));
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 4));
+
+        // Add the item again, ensuring autosave is prevented if the underlying file has been deleted.
+        workspace.update(cx, |workspace, cx| {
+            workspace.add_item(Box::new(item.clone()), cx);
+        });
+        item.update(cx, |item, cx| {
+            item.project_entry_ids = Default::default();
+            item.is_dirty = true;
+            cx.blur();
+        });
+        deterministic.run_until_parked();
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 4));
+
+        // Ensure autosave is prevented for deleted files also when closing the buffer.
+        let _close_items = workspace.update(cx, |workspace, cx| {
+            let pane = workspace.active_pane().clone();
+            Pane::close_items(workspace, pane, cx, move |id| id == item_id)
+        });
+        deterministic.run_until_parked();
+        assert!(cx.has_pending_prompt(window_id));
+        item.read_with(cx, |item, _| assert_eq!(item.save_count, 4));
+    }
+
     #[derive(Clone)]
     struct TestItem {
         save_count: usize,
@@ -2981,6 +3142,10 @@ mod tests {
         is_singleton: bool,
     }
 
+    enum TestItemEvent {
+        Edit,
+    }
+
     impl TestItem {
         fn new() -> Self {
             Self {
@@ -2997,7 +3162,7 @@ mod tests {
     }
 
     impl Entity for TestItem {
-        type Event = ();
+        type Event = TestItemEvent;
     }
 
     impl View for TestItem {
@@ -3054,6 +3219,7 @@ mod tests {
             _: &mut ViewContext<Self>,
         ) -> Task<anyhow::Result<()>> {
             self.save_count += 1;
+            self.is_dirty = false;
             Task::ready(Ok(()))
         }
 
@@ -3064,6 +3230,7 @@ mod tests {
             _: &mut ViewContext<Self>,
         ) -> Task<anyhow::Result<()>> {
             self.save_as_count += 1;
+            self.is_dirty = false;
             Task::ready(Ok(()))
         }
 
@@ -3073,11 +3240,16 @@ mod tests {
             _: &mut ViewContext<Self>,
         ) -> Task<anyhow::Result<()>> {
             self.reload_count += 1;
+            self.is_dirty = false;
             Task::ready(Ok(()))
         }
 
         fn should_update_tab_on_event(_: &Self::Event) -> bool {
             true
         }
+
+        fn is_edit_event(event: &Self::Event) -> bool {
+            matches!(event, TestItemEvent::Edit)
+        }
     }
 }