Track dirtyness per item (#26237)

Conrad Irwin created

This reduces the number of multibuffer syncs when starting the editor
with 80
files open in the Zed repo from 10,000,000 to 100,000 by avoiding
O(n**2)
dirtyness checks.

Release Notes:

- Fixed a beachball when restarting in a large repo with a large number
open files

Change summary

crates/workspace/src/item.rs      |  1 
crates/workspace/src/workspace.rs | 43 ++++++++++++++++++++++++++++----
crates/zed/src/zed.rs             |  6 +++
3 files changed, 43 insertions(+), 7 deletions(-)

Detailed changes

crates/workspace/src/item.rs 🔗

@@ -802,6 +802,7 @@ impl<T: Item> ItemHandle for Entity<T> {
                         }
 
                         ItemEvent::UpdateTab => {
+                            workspace.update_item_dirty_state(item, window, cx);
                             pane.update(cx, |_, cx| {
                                 cx.emit(pane::Event::ChangeItemTitle);
                                 cx.notify();

crates/workspace/src/workspace.rs 🔗

@@ -826,6 +826,7 @@ pub struct Workspace {
     follower_states: HashMap<PeerId, FollowerState>,
     last_leaders_by_pane: HashMap<WeakEntity<Pane>, PeerId>,
     window_edited: bool,
+    dirty_items: HashMap<EntityId, Subscription>,
     active_call: Option<(Entity<ActiveCall>, Vec<Subscription>)>,
     leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
     database_id: Option<WorkspaceId>,
@@ -1128,6 +1129,7 @@ impl Workspace {
             last_leaders_by_pane: Default::default(),
             dispatching_keystrokes: Default::default(),
             window_edited: false,
+            dirty_items: Default::default(),
             active_call,
             database_id: workspace_id,
             app_state,
@@ -3395,13 +3397,11 @@ impl Workspace {
                 if *pane == self.active_pane {
                     self.active_item_path_changed(window, cx);
                 }
-                self.update_window_edited(window, cx);
                 serialize_workspace = false;
             }
             pane::Event::RemoveItem { .. } => {}
             pane::Event::RemovedItem { item_id } => {
                 cx.emit(Event::ActiveItemChanged);
-                self.update_window_edited(window, cx);
                 if let hash_map::Entry::Occupied(entry) = self.panes_by_item.entry(*item_id) {
                     if entry.get().entity_id() == pane.entity_id() {
                         entry.remove();
@@ -3858,16 +3858,47 @@ impl Workspace {
     }
 
     fn update_window_edited(&mut self, window: &mut Window, cx: &mut App) {
-        let is_edited = !self.project.read(cx).is_disconnected(cx)
-            && self
-                .items(cx)
-                .any(|item| item.has_conflict(cx) || item.is_dirty(cx));
+        let is_edited = !self.project.read(cx).is_disconnected(cx) && !self.dirty_items.is_empty();
         if is_edited != self.window_edited {
             self.window_edited = is_edited;
             window.set_window_edited(self.window_edited)
         }
     }
 
+    fn update_item_dirty_state(
+        &mut self,
+        item: &dyn ItemHandle,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
+        let is_dirty = item.is_dirty(cx);
+        let item_id = item.item_id();
+        let was_dirty = self.dirty_items.contains_key(&item_id);
+        if is_dirty == was_dirty {
+            return;
+        }
+        if was_dirty {
+            self.dirty_items.remove(&item_id);
+            self.update_window_edited(window, cx);
+            return;
+        }
+        if let Some(window_handle) = window.window_handle().downcast::<Self>() {
+            let s = item.on_release(
+                cx,
+                Box::new(move |cx| {
+                    window_handle
+                        .update(cx, |this, window, cx| {
+                            this.dirty_items.remove(&item_id);
+                            this.update_window_edited(window, cx)
+                        })
+                        .ok();
+                }),
+            );
+            self.dirty_items.insert(item_id, s);
+            self.update_window_edited(window, cx);
+        }
+    }
+
     fn render_notifications(&self, _window: &mut Window, _cx: &mut Context<Self>) -> Option<Div> {
         if self.notifications.is_empty() {
             None

crates/zed/src/zed.rs 🔗

@@ -2112,6 +2112,7 @@ mod tests {
             })
             .unwrap();
         assert!(window_is_edited(window, cx));
+        let weak = editor.downgrade();
 
         // Closing the item restores the window's edited state.
         let close = window
@@ -2127,11 +2128,12 @@ mod tests {
 
         cx.simulate_prompt_answer("Don't Save");
         close.await.unwrap();
-        assert!(!window_is_edited(window, cx));
 
         // Advance the clock to ensure that the item has been serialized and dropped from the queue
         cx.executor().advance_clock(Duration::from_secs(1));
 
+        weak.assert_released();
+        assert!(!window_is_edited(window, cx));
         // Opening the buffer again doesn't impact the window's edited state.
         cx.update(|cx| {
             open_paths(
@@ -2266,6 +2268,8 @@ mod tests {
         assert_eq!(cx.update(|cx| cx.windows().len()), 1);
         assert!(cx.update(|cx| cx.active_window().is_some()));
 
+        cx.run_until_parked();
+
         // When opening the workspace, the window is not in a edited state.
         let window = cx.update(|cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
         assert!(window_is_edited(window, cx));