editor: Batch calls to fold_buffer in ProjectDiff::refresh (#49278)

Jakub Konka created

This change improves performance of project diff in that:
* scrolling in split view for very large diffs (think chromium repo with
`git reset HEAD~1000`) is now very smooth on macOS and fairly smooth on
Linux
* switching from split to unified is very smooth on macOS, and fairly
smooth on Linux

There still remains the case of (severe) hangs when switching from
unified to split however, but it will be addressed in a follow-up PR.

Anyhow, here's the screenshot of the Instruments.app capture of opening
chromium repo in Zed in split view, scrolling a little, moving to
unified, scrolling some more, and moving back to split. Prior to this
change, split -> unified would cause a severe hang, whereas now it's a
hang and thus feels much smoother already (without Instruments profiling
is barely visible). Unified -> split severe hangs are still there but
don't last as long.

<img width="2301" height="374" alt="Screenshot 2026-02-16 at 5 46 23 PM"
src="https://github.com/user-attachments/assets/f687f8d4-cffd-47f1-ada1-f6c4d3ac3cd4"
/>

Release Notes:

- Improved project diff performance when opening very large
diffs/repositories.

Change summary

crates/editor/src/editor.rs                     | 41 +++++++++++--
crates/editor/src/highlight_matching_bracket.rs | 55 +++++++++---------
crates/git_ui/src/commit_view.rs                |  4 -
crates/git_ui/src/project_diff.rs               | 25 ++++++-
4 files changed, 82 insertions(+), 43 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -3625,7 +3625,7 @@ impl Editor {
             refresh_linked_ranges(self, window, cx);
 
             self.refresh_selected_text_highlights(false, window, cx);
-            self.refresh_matching_bracket_highlights(window, cx);
+            self.refresh_matching_bracket_highlights(&display_map, cx);
             self.refresh_outline_symbols_at_cursor(cx);
             self.update_visible_edit_prediction(window, cx);
             self.edit_prediction_requires_modifier_in_indent_conflict = true;
@@ -20304,22 +20304,46 @@ impl Editor {
     }
 
     pub fn fold_buffer(&mut self, buffer_id: BufferId, cx: &mut Context<Self>) {
-        if self.buffer().read(cx).is_singleton() || self.is_buffer_folded(buffer_id, cx) {
+        self.fold_buffers([buffer_id], cx);
+    }
+
+    pub fn fold_buffers(
+        &mut self,
+        buffer_ids: impl IntoIterator<Item = BufferId>,
+        cx: &mut Context<Self>,
+    ) {
+        if self.buffer().read(cx).is_singleton() {
             return;
         }
 
-        let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(buffer_id, cx);
+        let ids_to_fold: Vec<BufferId> = buffer_ids
+            .into_iter()
+            .filter(|id| !self.is_buffer_folded(*id, cx))
+            .collect();
+
+        if ids_to_fold.is_empty() {
+            return;
+        }
+
+        let mut all_folded_excerpt_ids = Vec::new();
+        for buffer_id in &ids_to_fold {
+            let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(*buffer_id, cx);
+            all_folded_excerpt_ids.extend(folded_excerpts.into_iter().map(|(id, _)| id));
+        }
+
         self.display_map.update(cx, |display_map, cx| {
-            display_map.fold_buffers([buffer_id], cx)
+            display_map.fold_buffers(ids_to_fold.clone(), cx)
         });
 
         let snapshot = self.display_snapshot(cx);
         self.selections.change_with(&snapshot, |selections| {
-            selections.remove_selections_from_buffer(buffer_id);
+            for buffer_id in ids_to_fold {
+                selections.remove_selections_from_buffer(buffer_id);
+            }
         });
 
         cx.emit(EditorEvent::BufferFoldToggled {
-            ids: folded_excerpts.iter().map(|&(id, _)| id).collect(),
+            ids: all_folded_excerpt_ids,
             folded: true,
         });
         cx.notify();
@@ -23906,9 +23930,10 @@ impl Editor {
                 self.refresh_active_diagnostics(cx);
                 self.refresh_code_actions(window, cx);
                 self.refresh_single_line_folds(window, cx);
-                self.refresh_matching_bracket_highlights(window, cx);
+                let snapshot = self.snapshot(window, cx);
+                self.refresh_matching_bracket_highlights(&snapshot, cx);
                 self.refresh_outline_symbols_at_cursor(cx);
-                self.refresh_sticky_headers(&self.snapshot(window, cx), cx);
+                self.refresh_sticky_headers(&snapshot, cx);
                 if self.has_active_edit_prediction() {
                     self.update_visible_edit_prediction(window, cx);
                 }

crates/editor/src/highlight_matching_bracket.rs 🔗

@@ -1,5 +1,5 @@
-use crate::{Editor, HighlightKey, RangeToAnchorExt};
-use gpui::{AppContext, Context, HighlightStyle, Window};
+use crate::{Editor, HighlightKey, RangeToAnchorExt, display_map::DisplaySnapshot};
+use gpui::{AppContext, Context, HighlightStyle};
 use language::CursorShape;
 use multi_buffer::MultiBufferOffset;
 use theme::ActiveTheme;
@@ -8,12 +8,11 @@ impl Editor {
     #[ztracing::instrument(skip_all)]
     pub fn refresh_matching_bracket_highlights(
         &mut self,
-        window: &Window,
+        snapshot: &DisplaySnapshot,
         cx: &mut Context<Editor>,
     ) {
         self.clear_highlights(HighlightKey::MatchingBracket, cx);
 
-        let snapshot = self.snapshot(window, cx);
         let newest_selection = self.selections.newest::<MultiBufferOffset>(&snapshot);
         // Don't highlight brackets if the selection isn't empty
         if !newest_selection.is_empty() {
@@ -39,29 +38,31 @@ impl Editor {
             let buffer_snapshot = buffer_snapshot.clone();
             async move { buffer_snapshot.innermost_enclosing_bracket_ranges(head..tail, None) }
         });
-        self.refresh_matching_bracket_highlights_task = cx.spawn(async move |editor, cx| {
-            if let Some((opening_range, closing_range)) = task.await {
-                let buffer_snapshot = snapshot.buffer_snapshot();
-                editor
-                    .update(cx, |editor, cx| {
-                        editor.highlight_text(
-                            HighlightKey::MatchingBracket,
-                            vec![
-                                opening_range.to_anchors(&buffer_snapshot),
-                                closing_range.to_anchors(&buffer_snapshot),
-                            ],
-                            HighlightStyle {
-                                background_color: Some(
-                                    cx.theme()
-                                        .colors()
-                                        .editor_document_highlight_bracket_background,
-                                ),
-                                ..Default::default()
-                            },
-                            cx,
-                        )
-                    })
-                    .ok();
+        self.refresh_matching_bracket_highlights_task = cx.spawn({
+            let buffer_snapshot = buffer_snapshot.clone();
+            async move |editor, cx| {
+                if let Some((opening_range, closing_range)) = task.await {
+                    editor
+                        .update(cx, |editor, cx| {
+                            editor.highlight_text(
+                                HighlightKey::MatchingBracket,
+                                vec![
+                                    opening_range.to_anchors(&buffer_snapshot),
+                                    closing_range.to_anchors(&buffer_snapshot),
+                                ],
+                                HighlightStyle {
+                                    background_color: Some(
+                                        cx.theme()
+                                            .colors()
+                                            .editor_document_highlight_bracket_background,
+                                    ),
+                                    ..Default::default()
+                                },
+                                cx,
+                            )
+                        })
+                        .ok();
+                }
             }
         });
     }

crates/git_ui/src/commit_view.rs 🔗

@@ -362,9 +362,7 @@ impl CommitView {
                 });
                 if !binary_buffer_ids.is_empty() {
                     this.editor.update(cx, |editor, cx| {
-                        for buffer_id in binary_buffer_ids {
-                            editor.fold_buffer(buffer_id, cx);
-                        }
+                        editor.fold_buffers(binary_buffer_ids, cx);
                     });
                 }
             })?;

crates/git_ui/src/project_diff.rs 🔗

@@ -24,7 +24,7 @@ use gpui::{
     Action, AnyElement, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
     FocusHandle, Focusable, Render, Subscription, Task, WeakEntity, actions,
 };
-use language::{Anchor, Buffer, Capability, OffsetRangeExt};
+use language::{Anchor, Buffer, BufferId, Capability, OffsetRangeExt};
 use multi_buffer::{MultiBuffer, PathKey};
 use project::{
     Project, ProjectPath,
@@ -609,7 +609,7 @@ impl ProjectDiff {
         diff: Entity<BufferDiff>,
         window: &mut Window,
         cx: &mut Context<Self>,
-    ) {
+    ) -> Option<BufferId> {
         let subscription = cx.subscribe_in(&diff, window, move |this, _, _, window, cx| {
             this._task = window.spawn(cx, {
                 let this = cx.weak_entity();
@@ -654,6 +654,8 @@ impl ProjectDiff {
             }
         };
 
+        let mut needs_fold = None;
+
         let (was_empty, is_excerpt_newly_added) = self.editor.update(cx, |editor, cx| {
             let was_empty = editor.rhs_editor().read(cx).buffer().read(cx).is_empty();
             let (_, is_newly_added) = editor.set_excerpts_for_path(
@@ -686,7 +688,7 @@ impl ProjectDiff {
                         || (file_status.is_untracked()
                             && GitPanelSettings::get_global(cx).collapse_untracked_diff))
                 {
-                    editor.fold_buffer(snapshot.text.remote_id(), cx)
+                    needs_fold = Some(snapshot.text.remote_id());
                 }
             })
         });
@@ -707,6 +709,8 @@ impl ProjectDiff {
         if self.pending_scroll.as_ref() == Some(&path_key) {
             self.move_to_path(path_key, window, cx);
         }
+
+        needs_fold
     }
 
     #[instrument(skip_all)]
@@ -762,6 +766,8 @@ impl ProjectDiff {
             buffers_to_load
         })?;
 
+        let mut buffers_to_fold = Vec::new();
+
         for (entry, path_key) in buffers_to_load.into_iter().zip(path_keys.into_iter()) {
             if let Some((buffer, diff)) = entry.load.await.log_err() {
                 // We might be lagging behind enough that all future entry.load futures are no longer pending.
@@ -781,14 +787,16 @@ impl ProjectDiff {
                                 RefreshReason::StatusesChanged => false,
                             };
                         if !skip {
-                            this.register_buffer(
+                            if let Some(buffer_id) = this.register_buffer(
                                 path_key,
                                 entry.file_status,
                                 buffer,
                                 diff,
                                 window,
                                 cx,
-                            )
+                            ) {
+                                buffers_to_fold.push(buffer_id);
+                            }
                         }
                     })
                     .ok();
@@ -796,6 +804,13 @@ impl ProjectDiff {
             }
         }
         this.update(cx, |this, cx| {
+            if !buffers_to_fold.is_empty() {
+                this.editor.update(cx, |editor, cx| {
+                    editor
+                        .rhs_editor()
+                        .update(cx, |editor, cx| editor.fold_buffers(buffers_to_fold, cx));
+                });
+            }
             this.pending_scroll.take();
             cx.notify();
         })?;