Speed up is_dirty and has_conflict (#11946)

Max Brunsfeld created

I noticed that scrolling the assistant panel was very slow in debug
mode, after running a completion. From profiling, I saw that it was due
to the buffer's `is_dirty` and `has_conflict` checks, which use
`edits_since` to check if there are any non-undone edits since the saved
version.

I optimized this in two ways:
* I introduced a specialized `has_edits_since` method on text buffers,
which allows us to more cheaply check if the buffer has been edited
since a given version, without some of the overhead involved in
computing what the edits actually are.
* In the case of `has_conflict`, we don't even need to call that method
in the case where the buffer doesn't have a file (is untitled, as is the
case in the assistant panel). Buffers without files cannot be in
conflict.

Release Notes:

- Improved performance of editing the assistant panel and untitled
buffers with many edits.

Change summary

crates/language/src/buffer.rs | 17 ++++++-----------
crates/text/src/tests.rs      |  8 ++++++++
crates/text/src/text.rs       | 18 ++++++++++++++++++
3 files changed, 32 insertions(+), 11 deletions(-)

Detailed changes

crates/language/src/buffer.rs 🔗

@@ -1527,14 +1527,10 @@ impl Buffer {
         self.end_transaction(cx)
     }
 
-    fn changed_since_saved_version(&self) -> bool {
-        self.edits_since::<usize>(&self.saved_version)
-            .next()
-            .is_some()
-    }
     /// Checks if the buffer has unsaved changes.
     pub fn is_dirty(&self) -> bool {
-        (self.has_conflict || self.changed_since_saved_version())
+        self.has_conflict
+            || self.has_edits_since(&self.saved_version)
             || self
                 .file
                 .as_ref()
@@ -1544,11 +1540,10 @@ impl Buffer {
     /// Checks if the buffer and its file have both changed since the buffer
     /// was last saved or reloaded.
     pub fn has_conflict(&self) -> bool {
-        (self.has_conflict || self.changed_since_saved_version())
-            && self
-                .file
-                .as_ref()
-                .map_or(false, |file| file.mtime() > self.saved_mtime)
+        self.has_conflict
+            || self.file.as_ref().map_or(false, |file| {
+                file.mtime() > self.saved_mtime && self.has_edits_since(&self.saved_version)
+            })
     }
 
     /// Gets a [`Subscription`] that tracks all of the changes to the buffer's text.

crates/text/src/tests.rs 🔗

@@ -139,6 +139,14 @@ fn test_random_edits(mut rng: StdRng) {
             assert_eq!(old_text, new_text);
         }
 
+        assert_eq!(
+            buffer.has_edits_since(&old_buffer.version),
+            buffer
+                .edits_since::<usize>(&old_buffer.version)
+                .next()
+                .is_some(),
+        );
+
         let subscription_edits = subscription.consume();
         log::info!(
             "applying subscription edits since version {:?} to old text: {:?}: {:?}",

crates/text/src/text.rs 🔗

@@ -2165,6 +2165,24 @@ impl BufferSnapshot {
             buffer_id: self.remote_id,
         }
     }
+
+    pub fn has_edits_since(&self, since: &clock::Global) -> bool {
+        if *since != self.version {
+            let mut cursor = self
+                .fragments
+                .filter::<_, usize>(move |summary| !since.observed_all(&summary.max_version));
+            cursor.next(&None);
+            while let Some(fragment) = cursor.item() {
+                let was_visible = fragment.was_visible(since, &self.undo_map);
+                let is_visible = fragment.visible;
+                if was_visible != is_visible {
+                    return true;
+                }
+                cursor.next(&None);
+            }
+        }
+        false
+    }
 }
 
 struct RopeBuilder<'a> {