diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 94eda67c39..ecc1b2df68 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/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) { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 31a636fd61..9dd40413fd 100644 --- a/crates/editor/src/editor.rs +++ b/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, hover_state: HoverState, link_go_to_definition_state: LinkGoToDefinitionState, - pending_autosave: Option>>, - cancel_pending_autosave: Option>, _subscriptions: Vec, } @@ -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::().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) { - if !active && cx.global::().autosave == Autosave::OnWindowChange { - self.autosave(cx).detach_and_log_err(cx); - } - } - - fn autosave(&mut self, cx: &mut ViewContext) -> Task> { - 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::().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, 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( diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 8e15dce83c..f7aa80beaa 100644 --- a/crates/editor/src/items.rs +++ b/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 { diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 2aa8993285..5ee2dcbb27 100644 --- a/crates/search/src/project_search.rs +++ b/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 { diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 5e039b8cd0..f8474e41b1 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -718,6 +718,18 @@ impl Pane { Ok(true) } + pub fn autosave_item( + item: &dyn ItemHandle, + project: ModelHandle, + cx: &mut MutableAppContext, + ) -> Task> { + 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) { if let Some(active_item) = self.active_item() { cx.focus(active_item); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 419998e730..cf1092662f 100644 --- a/crates/workspace/src/workspace.rs +++ b/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 ItemHandle for ViewHandle { } } + 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 ItemHandle for ViewHandle { cx.notify(); }); } + + if T::is_edit_event(event) { + if let Autosave::AfterDelay { milliseconds } = cx.global::().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::().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) { + if !active && cx.global::().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 { diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index f88aee3d7c..4fa2e238bf 100644 --- a/crates/zed/src/zed.rs +++ b/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, 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::().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);