Move autosave logic up into `Workspace` and `Pane`

Antonio Scandurra created

Change summary

crates/diagnostics/src/diagnostics.rs |   9 +
crates/editor/src/editor.rs           | 131 ----------------------------
crates/editor/src/items.rs            |   4 
crates/search/src/project_search.rs   |   8 +
crates/workspace/src/pane.rs          |  12 ++
crates/workspace/src/workspace.rs     |  59 ++++++++++++
crates/zed/src/zed.rs                 |  75 ++++++++++++++++
7 files changed, 166 insertions(+), 132 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/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 {

crates/workspace/src/pane.rs 🔗

@@ -718,6 +718,18 @@ impl Pane {
         Ok(true)
     }
 
+    pub fn autosave_item(
+        item: &dyn ItemHandle,
+        project: ModelHandle<Project>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<()>> {
+        if item.is_dirty(cx) && !item.has_conflict(cx) && item.can_save(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 {

crates/zed/src/zed.rs 🔗

@@ -396,9 +396,11 @@ mod tests {
     };
     use project::{Project, ProjectPath};
     use serde_json::json;
+    use settings::Autosave;
     use std::{
         collections::HashSet,
         path::{Path, PathBuf},
+        time::Duration,
     };
     use theme::{Theme, ThemeRegistry, DEFAULT_THEME_NAME};
     use workspace::{
@@ -977,6 +979,79 @@ mod tests {
         })
     }
 
+    #[gpui::test]
+    async fn test_autosave(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
+        let app_state = init(cx);
+        let fs = app_state.fs.clone();
+        fs.as_fake()
+            .insert_tree("/root", json!({ "a.txt": "" }))
+            .await;
+
+        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+        let (_, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
+        cx.update(|cx| {
+            workspace.update(cx, |view, cx| {
+                view.open_paths(vec![PathBuf::from("/root/a.txt")], true, cx)
+            })
+        })
+        .await;
+        let editor = cx.read(|cx| {
+            let pane = workspace.read(cx).active_pane().read(cx);
+            let item = pane.active_item().unwrap();
+            item.downcast::<Editor>().unwrap()
+        });
+
+        // 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("/root/a.txt")).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("/root/a.txt")).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("/root/a.txt")).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("/root/a.txt")).await.unwrap(), "XXX");
+        editor.read_with(cx, |editor, cx| assert!(!editor.is_dirty(cx)));
+    }
+
     #[gpui::test]
     async fn test_setting_language_when_saving_as_single_file_worktree(cx: &mut TestAppContext) {
         let app_state = init(cx);