Refine inline transformation UX (#12939)

Antonio Scandurra , Richard , and Nathan created

https://github.com/zed-industries/zed/assets/482957/1790e32e-1f59-4831-8a4c-722cf441e7e9



Release Notes:

- N/A

---------

Co-authored-by: Richard <richard@zed.dev>
Co-authored-by: Nathan <nathan@zed.dev>

Change summary

crates/assistant/src/assistant_panel.rs              |  14 
crates/assistant/src/inline_assistant.rs             | 198 +++++++++----
crates/assistant/src/prompt_library.rs               |  14 
crates/collab_ui/src/channel_view.rs                 |   2 
crates/editor/src/editor.rs                          |  90 ++++--
crates/editor/src/editor_tests.rs                    |  59 ++-
crates/editor/src/element.rs                         |   2 
crates/editor/src/hunk_diff.rs                       |  56 ++-
crates/editor/src/items.rs                           |   2 
crates/extensions_ui/src/extensions_ui.rs            |   2 
crates/feedback/src/feedback_modal.rs                |   2 
crates/go_to_line/src/go_to_line.rs                  |  22 
crates/gpui/src/subscription.rs                      |   8 
crates/markdown_preview/src/markdown_preview_view.rs |   2 
crates/multi_buffer/src/multi_buffer.rs              |  82 +++++
crates/outline/src/outline.rs                        |  12 
crates/search/src/buffer_search.rs                   |   2 
crates/text/src/text.rs                              |  26 +
crates/vim/src/vim.rs                                |   4 
crates/zed/src/zed/inline_completion_registry.rs     |   9 
20 files changed, 404 insertions(+), 204 deletions(-)

Detailed changes

crates/assistant/src/assistant_panel.rs 🔗

@@ -79,7 +79,6 @@ pub fn init(cx: &mut AppContext) {
                     workspace.toggle_panel_focus::<AssistantPanel>(cx);
                 })
                 .register_action(AssistantPanel::inline_assist)
-                .register_action(AssistantPanel::cancel_last_inline_assist)
                 .register_action(ContextEditor::quote_selection);
         },
     )
@@ -421,19 +420,6 @@ impl AssistantPanel {
         }
     }
 
-    fn cancel_last_inline_assist(
-        _workspace: &mut Workspace,
-        _: &editor::actions::Cancel,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        let canceled = InlineAssistant::update_global(cx, |assistant, cx| {
-            assistant.cancel_last_inline_assist(cx)
-        });
-        if !canceled {
-            cx.propagate();
-        }
-    }
-
     fn new_context(&mut self, cx: &mut ViewContext<Self>) -> Option<View<ContextEditor>> {
         let workspace = self.workspace.upgrade()?;
 

crates/assistant/src/inline_assistant.rs 🔗

@@ -16,8 +16,8 @@ use editor::{
 };
 use futures::{channel::mpsc, SinkExt, Stream, StreamExt};
 use gpui::{
-    AnyWindowHandle, AppContext, EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight,
-    Global, HighlightStyle, Model, ModelContext, Subscription, Task, TextStyle, UpdateGlobal, View,
+    AppContext, EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, Global,
+    HighlightStyle, Model, ModelContext, Subscription, Task, TextStyle, UpdateGlobal, View,
     ViewContext, WeakView, WhiteSpace, WindowContext,
 };
 use language::{Buffer, Point, TransactionId};
@@ -34,6 +34,7 @@ use std::{
 };
 use theme::ThemeSettings;
 use ui::{prelude::*, Tooltip};
+use util::RangeExt;
 use workspace::{notifications::NotificationId, Toast, Workspace};
 
 pub fn init(telemetry: Arc<Telemetry>, cx: &mut AppContext) {
@@ -45,16 +46,11 @@ const PROMPT_HISTORY_MAX_LEN: usize = 20;
 pub struct InlineAssistant {
     next_assist_id: InlineAssistId,
     pending_assists: HashMap<InlineAssistId, PendingInlineAssist>,
-    pending_assist_ids_by_editor: HashMap<WeakView<Editor>, EditorPendingAssists>,
+    pending_assist_ids_by_editor: HashMap<WeakView<Editor>, Vec<InlineAssistId>>,
     prompt_history: VecDeque<String>,
     telemetry: Option<Arc<Telemetry>>,
 }
 
-struct EditorPendingAssists {
-    window: AnyWindowHandle,
-    assist_ids: Vec<InlineAssistId>,
-}
-
 impl Global for InlineAssistant {}
 
 impl InlineAssistant {
@@ -103,7 +99,7 @@ impl InlineAssistant {
             }
         };
 
-        let inline_assist_id = self.next_assist_id.post_inc();
+        let assist_id = self.next_assist_id.post_inc();
         let codegen = cx.new_model(|cx| {
             Codegen::new(
                 editor.read(cx).buffer().clone(),
@@ -116,7 +112,7 @@ impl InlineAssistant {
         let gutter_dimensions = Arc::new(Mutex::new(GutterDimensions::default()));
         let prompt_editor = cx.new_view(|cx| {
             InlineAssistEditor::new(
-                inline_assist_id,
+                assist_id,
                 gutter_dimensions.clone(),
                 self.prompt_history.clone(),
                 codegen.clone(),
@@ -164,7 +160,7 @@ impl InlineAssistant {
         });
 
         self.pending_assists.insert(
-            inline_assist_id,
+            assist_id,
             PendingInlineAssist {
                 include_context,
                 editor: editor.downgrade(),
@@ -179,24 +175,35 @@ impl InlineAssistant {
                 _subscriptions: vec![
                     cx.subscribe(&prompt_editor, |inline_assist_editor, event, cx| {
                         InlineAssistant::update_global(cx, |this, cx| {
-                            this.handle_inline_assistant_event(inline_assist_editor, event, cx)
+                            this.handle_inline_assistant_editor_event(
+                                inline_assist_editor,
+                                event,
+                                cx,
+                            )
                         })
                     }),
-                    cx.subscribe(editor, {
-                        let inline_assist_editor = prompt_editor.downgrade();
-                        move |editor, event, cx| {
-                            if let Some(inline_assist_editor) = inline_assist_editor.upgrade() {
-                                if let EditorEvent::SelectionsChanged { local } = event {
-                                    if *local
-                                        && inline_assist_editor
-                                            .focus_handle(cx)
-                                            .contains_focused(cx)
-                                    {
-                                        cx.focus_view(&editor);
-                                    }
-                                }
-                            }
-                        }
+                    editor.update(cx, |editor, _cx| {
+                        editor.register_action(
+                            move |_: &editor::actions::Newline, cx: &mut WindowContext| {
+                                InlineAssistant::update_global(cx, |this, cx| {
+                                    this.handle_editor_action(assist_id, false, cx)
+                                })
+                            },
+                        )
+                    }),
+                    editor.update(cx, |editor, _cx| {
+                        editor.register_action(
+                            move |_: &editor::actions::Cancel, cx: &mut WindowContext| {
+                                InlineAssistant::update_global(cx, |this, cx| {
+                                    this.handle_editor_action(assist_id, true, cx)
+                                })
+                            },
+                        )
+                    }),
+                    cx.subscribe(editor, move |editor, event, cx| {
+                        InlineAssistant::update_global(cx, |this, cx| {
+                            this.handle_editor_event(assist_id, editor, event, cx)
+                        })
                     }),
                     cx.observe(&codegen, {
                         let editor = editor.downgrade();
@@ -204,19 +211,17 @@ impl InlineAssistant {
                             if let Some(editor) = editor.upgrade() {
                                 InlineAssistant::update_global(cx, |this, cx| {
                                     this.update_editor_highlights(&editor, cx);
-                                    this.update_editor_blocks(&editor, inline_assist_id, cx);
+                                    this.update_editor_blocks(&editor, assist_id, cx);
                                 })
                             }
                         }
                     }),
                     cx.subscribe(&codegen, move |codegen, event, cx| {
                         InlineAssistant::update_global(cx, |this, cx| match event {
-                            CodegenEvent::Undone => {
-                                this.finish_inline_assist(inline_assist_id, false, cx)
-                            }
+                            CodegenEvent::Undone => this.finish_inline_assist(assist_id, false, cx),
                             CodegenEvent::Finished => {
                                 let pending_assist = if let Some(pending_assist) =
-                                    this.pending_assists.get(&inline_assist_id)
+                                    this.pending_assists.get(&assist_id)
                                 {
                                     pending_assist
                                 } else {
@@ -238,7 +243,7 @@ impl InlineAssistant {
                                                 let id = NotificationId::identified::<
                                                     InlineAssistantError,
                                                 >(
-                                                    inline_assist_id.0
+                                                    assist_id.0
                                                 );
 
                                                 workspace.show_toast(Toast::new(id, error), cx);
@@ -248,7 +253,7 @@ impl InlineAssistant {
                                 }
 
                                 if pending_assist.editor_decorations.is_none() {
-                                    this.finish_inline_assist(inline_assist_id, false, cx);
+                                    this.finish_inline_assist(assist_id, false, cx);
                                 }
                             }
                         })
@@ -259,16 +264,12 @@ impl InlineAssistant {
 
         self.pending_assist_ids_by_editor
             .entry(editor.downgrade())
-            .or_insert_with(|| EditorPendingAssists {
-                window: cx.window_handle(),
-                assist_ids: Vec::new(),
-            })
-            .assist_ids
-            .push(inline_assist_id);
+            .or_default()
+            .push(assist_id);
         self.update_editor_highlights(editor, cx);
     }
 
-    fn handle_inline_assistant_event(
+    fn handle_inline_assistant_editor_event(
         &mut self,
         inline_assist_editor: View<InlineAssistEditor>,
         event: &InlineAssistEditorEvent,
@@ -289,7 +290,7 @@ impl InlineAssistant {
                 self.finish_inline_assist(assist_id, true, cx);
             }
             InlineAssistEditorEvent::Dismissed => {
-                self.hide_inline_assist_decorations(assist_id, cx);
+                self.dismiss_inline_assist(assist_id, cx);
             }
             InlineAssistEditorEvent::Resized { height_in_lines } => {
                 self.resize_inline_assist(assist_id, *height_in_lines, cx);
@@ -297,20 +298,87 @@ impl InlineAssistant {
         }
     }
 
-    pub fn cancel_last_inline_assist(&mut self, cx: &mut WindowContext) -> bool {
-        for (editor, pending_assists) in &self.pending_assist_ids_by_editor {
-            if pending_assists.window == cx.window_handle() {
-                if let Some(editor) = editor.upgrade() {
-                    if editor.read(cx).is_focused(cx) {
-                        if let Some(assist_id) = pending_assists.assist_ids.last().copied() {
-                            self.finish_inline_assist(assist_id, true, cx);
-                            return true;
-                        }
+    fn handle_editor_action(
+        &mut self,
+        assist_id: InlineAssistId,
+        undo: bool,
+        cx: &mut WindowContext,
+    ) {
+        let Some(assist) = self.pending_assists.get(&assist_id) else {
+            return;
+        };
+        let Some(editor) = assist.editor.upgrade() else {
+            return;
+        };
+
+        let buffer = editor.read(cx).buffer().read(cx).snapshot(cx);
+        let assist_range = assist.codegen.read(cx).range().to_offset(&buffer);
+        let editor = editor.read(cx);
+        if editor.selections.count() == 1 {
+            let selection = editor.selections.newest::<usize>(cx);
+            if assist_range.contains(&selection.start) && assist_range.contains(&selection.end) {
+                if undo {
+                    self.finish_inline_assist(assist_id, true, cx);
+                } else if matches!(assist.codegen.read(cx).status, CodegenStatus::Pending) {
+                    self.dismiss_inline_assist(assist_id, cx);
+                } else {
+                    self.finish_inline_assist(assist_id, false, cx);
+                }
+
+                return;
+            }
+        }
+
+        cx.propagate();
+    }
+
+    fn handle_editor_event(
+        &mut self,
+        assist_id: InlineAssistId,
+        editor: View<Editor>,
+        event: &EditorEvent,
+        cx: &mut WindowContext,
+    ) {
+        let Some(assist) = self.pending_assists.get(&assist_id) else {
+            return;
+        };
+
+        match event {
+            EditorEvent::SelectionsChanged { local } if *local => {
+                if let Some(decorations) = assist.editor_decorations.as_ref() {
+                    if decorations
+                        .prompt_editor
+                        .focus_handle(cx)
+                        .contains_focused(cx)
+                    {
+                        cx.focus_view(&editor);
                     }
                 }
             }
+            EditorEvent::Saved => {
+                if let CodegenStatus::Done = &assist.codegen.read(cx).status {
+                    self.finish_inline_assist(assist_id, false, cx)
+                }
+            }
+            EditorEvent::Edited { transaction_id }
+                if matches!(
+                    assist.codegen.read(cx).status,
+                    CodegenStatus::Error(_) | CodegenStatus::Done
+                ) =>
+            {
+                let buffer = editor.read(cx).buffer().read(cx);
+                let edited_ranges =
+                    buffer.edited_ranges_for_transaction::<usize>(*transaction_id, cx);
+                let assist_range = assist.codegen.read(cx).range().to_offset(&buffer.read(cx));
+                if edited_ranges
+                    .iter()
+                    .any(|range| range.overlaps(&assist_range))
+                {
+                    self.finish_inline_assist(assist_id, false, cx);
+                }
+            }
+            _ => {}
         }
-        false
     }
 
     fn finish_inline_assist(
@@ -319,15 +387,15 @@ impl InlineAssistant {
         undo: bool,
         cx: &mut WindowContext,
     ) {
-        self.hide_inline_assist_decorations(assist_id, cx);
+        self.dismiss_inline_assist(assist_id, cx);
 
         if let Some(pending_assist) = self.pending_assists.remove(&assist_id) {
             if let hash_map::Entry::Occupied(mut entry) = self
                 .pending_assist_ids_by_editor
                 .entry(pending_assist.editor.clone())
             {
-                entry.get_mut().assist_ids.retain(|id| *id != assist_id);
-                if entry.get().assist_ids.is_empty() {
+                entry.get_mut().retain(|id| *id != assist_id);
+                if entry.get().is_empty() {
                     entry.remove();
                 }
             }
@@ -344,11 +412,7 @@ impl InlineAssistant {
         }
     }
 
-    fn hide_inline_assist_decorations(
-        &mut self,
-        assist_id: InlineAssistId,
-        cx: &mut WindowContext,
-    ) -> bool {
+    fn dismiss_inline_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) -> bool {
         let Some(pending_assist) = self.pending_assists.get_mut(&assist_id) else {
             return false;
         };
@@ -558,16 +622,14 @@ impl InlineAssistant {
         let mut gutter_transformed_ranges = Vec::new();
         let mut foreground_ranges = Vec::new();
         let mut inserted_row_ranges = Vec::new();
-        let empty_inline_assist_ids = Vec::new();
-        let inline_assist_ids = self
+        let empty_assist_ids = Vec::new();
+        let assist_ids = self
             .pending_assist_ids_by_editor
             .get(&editor.downgrade())
-            .map_or(&empty_inline_assist_ids, |pending_assists| {
-                &pending_assists.assist_ids
-            });
+            .unwrap_or(&empty_assist_ids);
 
-        for inline_assist_id in inline_assist_ids {
-            if let Some(pending_assist) = self.pending_assists.get(inline_assist_id) {
+        for assist_id in assist_ids {
+            if let Some(pending_assist) = self.pending_assists.get(assist_id) {
                 let codegen = pending_assist.codegen.read(cx);
                 foreground_ranges.extend(codegen.last_equal_ranges().iter().cloned());
 
@@ -1025,7 +1087,7 @@ impl InlineAssistEditor {
         cx: &mut ViewContext<Self>,
     ) {
         match event {
-            EditorEvent::Edited => {
+            EditorEvent::Edited { .. } => {
                 let prompt = self.prompt_editor.read(cx).text(cx);
                 if self
                     .prompt_history_ix

crates/assistant/src/prompt_library.rs 🔗

@@ -592,19 +592,6 @@ impl PromptLibrary {
         }
     }
 
-    fn cancel_last_inline_assist(
-        &mut self,
-        _: &editor::actions::Cancel,
-        cx: &mut ViewContext<Self>,
-    ) {
-        let canceled = InlineAssistant::update_global(cx, |assistant, cx| {
-            assistant.cancel_last_inline_assist(cx)
-        });
-        if !canceled {
-            cx.propagate();
-        }
-    }
-
     fn handle_prompt_editor_event(
         &mut self,
         prompt_id: PromptId,
@@ -743,7 +730,6 @@ impl PromptLibrary {
                             div()
                                 .on_action(cx.listener(Self::focus_picker))
                                 .on_action(cx.listener(Self::inline_assist))
-                                .on_action(cx.listener(Self::cancel_last_inline_assist))
                                 .flex_grow()
                                 .h_full()
                                 .pt(Spacing::XXLarge.rems(cx))

crates/collab_ui/src/channel_view.rs 🔗

@@ -232,7 +232,7 @@ impl ChannelView {
                         this.focus_position_from_link(position.clone(), false, cx);
                         this._reparse_subscription.take();
                     }
-                    EditorEvent::Edited | EditorEvent::SelectionsChanged { local: true } => {
+                    EditorEvent::Edited { .. } | EditorEvent::SelectionsChanged { local: true } => {
                         this._reparse_subscription.take();
                     }
                     _ => {}

crates/editor/src/editor.rs 🔗

@@ -116,15 +116,16 @@ use serde::{Deserialize, Serialize};
 use settings::{update_settings_file, Settings, SettingsStore};
 use smallvec::SmallVec;
 use snippet::Snippet;
-use std::ops::Not as _;
 use std::{
     any::TypeId,
     borrow::Cow,
+    cell::RefCell,
     cmp::{self, Ordering, Reverse},
     mem,
     num::NonZeroU32,
-    ops::{ControlFlow, Deref, DerefMut, Range, RangeInclusive},
+    ops::{ControlFlow, Deref, DerefMut, Not as _, Range, RangeInclusive},
     path::Path,
+    rc::Rc,
     sync::Arc,
     time::{Duration, Instant},
 };
@@ -377,6 +378,19 @@ impl Default for EditorStyle {
 
 type CompletionId = usize;
 
+#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Debug, Default)]
+struct EditorActionId(usize);
+
+impl EditorActionId {
+    pub fn post_inc(&mut self) -> Self {
+        let answer = self.0;
+
+        *self = Self(answer + 1);
+
+        Self(answer)
+    }
+}
+
 // type GetFieldEditorTheme = dyn Fn(&theme::Theme) -> theme::FieldEditor;
 // type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option<HighlightStyle>;
 
@@ -512,7 +526,8 @@ pub struct Editor {
     gutter_dimensions: GutterDimensions,
     pub vim_replace_map: HashMap<Range<usize>, String>,
     style: Option<EditorStyle>,
-    editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
+    next_editor_action_id: EditorActionId,
+    editor_actions: Rc<RefCell<BTreeMap<EditorActionId, Box<dyn Fn(&mut ViewContext<Self>)>>>>,
     use_autoclose: bool,
     auto_replace_emoji_shortcode: bool,
     show_git_blame_gutter: bool,
@@ -1805,7 +1820,8 @@ impl Editor {
             style: None,
             show_cursor_names: false,
             hovered_cursors: Default::default(),
-            editor_actions: Default::default(),
+            next_editor_action_id: EditorActionId::default(),
+            editor_actions: Rc::default(),
             vim_replace_map: Default::default(),
             show_inline_completions: mode == EditorMode::Full,
             custom_context_menu: None,
@@ -6448,8 +6464,10 @@ impl Editor {
             return;
         }
 
-        if let Some(tx_id) = self.buffer.update(cx, |buffer, cx| buffer.undo(cx)) {
-            if let Some((selections, _)) = self.selection_history.transaction(tx_id).cloned() {
+        if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.undo(cx)) {
+            if let Some((selections, _)) =
+                self.selection_history.transaction(transaction_id).cloned()
+            {
                 self.change_selections(None, cx, |s| {
                     s.select_anchors(selections.to_vec());
                 });
@@ -6457,10 +6475,8 @@ impl Editor {
             self.request_autoscroll(Autoscroll::fit(), cx);
             self.unmark_text(cx);
             self.refresh_inline_completion(true, cx);
-            cx.emit(EditorEvent::Edited);
-            cx.emit(EditorEvent::TransactionUndone {
-                transaction_id: tx_id,
-            });
+            cx.emit(EditorEvent::Edited { transaction_id });
+            cx.emit(EditorEvent::TransactionUndone { transaction_id });
         }
     }
 
@@ -6469,8 +6485,9 @@ impl Editor {
             return;
         }
 
-        if let Some(tx_id) = self.buffer.update(cx, |buffer, cx| buffer.redo(cx)) {
-            if let Some((_, Some(selections))) = self.selection_history.transaction(tx_id).cloned()
+        if let Some(transaction_id) = self.buffer.update(cx, |buffer, cx| buffer.redo(cx)) {
+            if let Some((_, Some(selections))) =
+                self.selection_history.transaction(transaction_id).cloned()
             {
                 self.change_selections(None, cx, |s| {
                     s.select_anchors(selections.to_vec());
@@ -6479,7 +6496,7 @@ impl Editor {
             self.request_autoscroll(Autoscroll::fit(), cx);
             self.unmark_text(cx);
             self.refresh_inline_completion(true, cx);
-            cx.emit(EditorEvent::Edited);
+            cx.emit(EditorEvent::Edited { transaction_id });
         }
     }
 
@@ -9590,18 +9607,20 @@ impl Editor {
         now: Instant,
         cx: &mut ViewContext<Self>,
     ) -> Option<TransactionId> {
-        if let Some(tx_id) = self
+        if let Some(transaction_id) = self
             .buffer
             .update(cx, |buffer, cx| buffer.end_transaction_at(now, cx))
         {
-            if let Some((_, end_selections)) = self.selection_history.transaction_mut(tx_id) {
+            if let Some((_, end_selections)) =
+                self.selection_history.transaction_mut(transaction_id)
+            {
                 *end_selections = Some(self.selections.disjoint_anchors());
             } else {
                 log::error!("unexpectedly ended a transaction that wasn't started by this editor");
             }
 
-            cx.emit(EditorEvent::Edited);
-            Some(tx_id)
+            cx.emit(EditorEvent::Edited { transaction_id });
+            Some(transaction_id)
         } else {
             None
         }
@@ -11293,21 +11312,28 @@ impl Editor {
     pub fn register_action<A: Action>(
         &mut self,
         listener: impl Fn(&A, &mut WindowContext) + 'static,
-    ) -> &mut Self {
+    ) -> Subscription {
+        let id = self.next_editor_action_id.post_inc();
         let listener = Arc::new(listener);
+        self.editor_actions.borrow_mut().insert(
+            id,
+            Box::new(move |cx| {
+                let _view = cx.view().clone();
+                let cx = cx.window_context();
+                let listener = listener.clone();
+                cx.on_action(TypeId::of::<A>(), move |action, phase, cx| {
+                    let action = action.downcast_ref().unwrap();
+                    if phase == DispatchPhase::Bubble {
+                        listener(action, cx)
+                    }
+                })
+            }),
+        );
 
-        self.editor_actions.push(Box::new(move |cx| {
-            let _view = cx.view().clone();
-            let cx = cx.window_context();
-            let listener = listener.clone();
-            cx.on_action(TypeId::of::<A>(), move |action, phase, cx| {
-                let action = action.downcast_ref().unwrap();
-                if phase == DispatchPhase::Bubble {
-                    listener(action, cx)
-                }
-            })
-        }));
-        self
+        let editor_actions = self.editor_actions.clone();
+        Subscription::new(move || {
+            editor_actions.borrow_mut().remove(&id);
+        })
     }
 
     pub fn file_header_size(&self) -> u8 {
@@ -11764,7 +11790,9 @@ pub enum EditorEvent {
         ids: Vec<ExcerptId>,
     },
     BufferEdited,
-    Edited,
+    Edited {
+        transaction_id: clock::Lamport,
+    },
     Reparsed,
     Focused,
     Blurred,

crates/editor/src/editor_tests.rs 🔗

@@ -57,10 +57,10 @@ fn test_edit_events(cx: &mut TestAppContext) {
         let events = events.clone();
         |cx| {
             let view = cx.view().clone();
-            cx.subscribe(&view, move |_, _, event: &EditorEvent, _| {
-                if matches!(event, EditorEvent::Edited | EditorEvent::BufferEdited) {
-                    events.borrow_mut().push(("editor1", event.clone()));
-                }
+            cx.subscribe(&view, move |_, _, event: &EditorEvent, _| match event {
+                EditorEvent::Edited { .. } => events.borrow_mut().push(("editor1", "edited")),
+                EditorEvent::BufferEdited => events.borrow_mut().push(("editor1", "buffer edited")),
+                _ => {}
             })
             .detach();
             Editor::for_buffer(buffer.clone(), None, cx)
@@ -70,11 +70,16 @@ fn test_edit_events(cx: &mut TestAppContext) {
     let editor2 = cx.add_window({
         let events = events.clone();
         |cx| {
-            cx.subscribe(&cx.view().clone(), move |_, _, event: &EditorEvent, _| {
-                if matches!(event, EditorEvent::Edited | EditorEvent::BufferEdited) {
-                    events.borrow_mut().push(("editor2", event.clone()));
-                }
-            })
+            cx.subscribe(
+                &cx.view().clone(),
+                move |_, _, event: &EditorEvent, _| match event {
+                    EditorEvent::Edited { .. } => events.borrow_mut().push(("editor2", "edited")),
+                    EditorEvent::BufferEdited => {
+                        events.borrow_mut().push(("editor2", "buffer edited"))
+                    }
+                    _ => {}
+                },
+            )
             .detach();
             Editor::for_buffer(buffer.clone(), None, cx)
         }
@@ -87,9 +92,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
     assert_eq!(
         mem::take(&mut *events.borrow_mut()),
         [
-            ("editor1", EditorEvent::Edited),
-            ("editor1", EditorEvent::BufferEdited),
-            ("editor2", EditorEvent::BufferEdited),
+            ("editor1", "edited"),
+            ("editor1", "buffer edited"),
+            ("editor2", "buffer edited"),
         ]
     );
 
@@ -98,9 +103,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
     assert_eq!(
         mem::take(&mut *events.borrow_mut()),
         [
-            ("editor2", EditorEvent::Edited),
-            ("editor1", EditorEvent::BufferEdited),
-            ("editor2", EditorEvent::BufferEdited),
+            ("editor2", "edited"),
+            ("editor1", "buffer edited"),
+            ("editor2", "buffer edited"),
         ]
     );
 
@@ -109,9 +114,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
     assert_eq!(
         mem::take(&mut *events.borrow_mut()),
         [
-            ("editor1", EditorEvent::Edited),
-            ("editor1", EditorEvent::BufferEdited),
-            ("editor2", EditorEvent::BufferEdited),
+            ("editor1", "edited"),
+            ("editor1", "buffer edited"),
+            ("editor2", "buffer edited"),
         ]
     );
 
@@ -120,9 +125,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
     assert_eq!(
         mem::take(&mut *events.borrow_mut()),
         [
-            ("editor1", EditorEvent::Edited),
-            ("editor1", EditorEvent::BufferEdited),
-            ("editor2", EditorEvent::BufferEdited),
+            ("editor1", "edited"),
+            ("editor1", "buffer edited"),
+            ("editor2", "buffer edited"),
         ]
     );
 
@@ -131,9 +136,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
     assert_eq!(
         mem::take(&mut *events.borrow_mut()),
         [
-            ("editor2", EditorEvent::Edited),
-            ("editor1", EditorEvent::BufferEdited),
-            ("editor2", EditorEvent::BufferEdited),
+            ("editor2", "edited"),
+            ("editor1", "buffer edited"),
+            ("editor2", "buffer edited"),
         ]
     );
 
@@ -142,9 +147,9 @@ fn test_edit_events(cx: &mut TestAppContext) {
     assert_eq!(
         mem::take(&mut *events.borrow_mut()),
         [
-            ("editor2", EditorEvent::Edited),
-            ("editor1", EditorEvent::BufferEdited),
-            ("editor2", EditorEvent::BufferEdited),
+            ("editor2", "edited"),
+            ("editor1", "buffer edited"),
+            ("editor2", "buffer edited"),
         ]
     );
 

crates/editor/src/element.rs 🔗

@@ -153,7 +153,7 @@ impl EditorElement {
     fn register_actions(&self, cx: &mut WindowContext) {
         let view = &self.editor;
         view.update(cx, |editor, cx| {
-            for action in editor.editor_actions.iter() {
+            for action in editor.editor_actions.borrow().values() {
                 (action)(cx)
             }
         });

crates/editor/src/hunk_diff.rs 🔗

@@ -615,32 +615,36 @@ fn editor_with_deleted_text(
         ]);
         let original_multi_buffer_range = hunk.multi_buffer_range.clone();
         let diff_base_range = hunk.diff_base_byte_range.clone();
-        editor.register_action::<RevertSelectedHunks>(move |_, cx| {
-            parent_editor
-                .update(cx, |editor, cx| {
-                    let Some((buffer, original_text)) = editor.buffer().update(cx, |buffer, cx| {
-                        let (_, buffer, _) =
-                            buffer.excerpt_containing(original_multi_buffer_range.start, cx)?;
-                        let original_text =
-                            buffer.read(cx).diff_base()?.slice(diff_base_range.clone());
-                        Some((buffer, Arc::from(original_text.to_string())))
-                    }) else {
-                        return;
-                    };
-                    buffer.update(cx, |buffer, cx| {
-                        buffer.edit(
-                            Some((
-                                original_multi_buffer_range.start.text_anchor
-                                    ..original_multi_buffer_range.end.text_anchor,
-                                original_text,
-                            )),
-                            None,
-                            cx,
-                        )
-                    });
-                })
-                .ok();
-        });
+        editor
+            .register_action::<RevertSelectedHunks>(move |_, cx| {
+                parent_editor
+                    .update(cx, |editor, cx| {
+                        let Some((buffer, original_text)) =
+                            editor.buffer().update(cx, |buffer, cx| {
+                                let (_, buffer, _) = buffer
+                                    .excerpt_containing(original_multi_buffer_range.start, cx)?;
+                                let original_text =
+                                    buffer.read(cx).diff_base()?.slice(diff_base_range.clone());
+                                Some((buffer, Arc::from(original_text.to_string())))
+                            })
+                        else {
+                            return;
+                        };
+                        buffer.update(cx, |buffer, cx| {
+                            buffer.edit(
+                                Some((
+                                    original_multi_buffer_range.start.text_anchor
+                                        ..original_multi_buffer_range.end.text_anchor,
+                                    original_text,
+                                )),
+                                None,
+                                cx,
+                            )
+                        });
+                    })
+                    .ok();
+            })
+            .detach();
         editor
     });
 

crates/editor/src/items.rs 🔗

@@ -234,7 +234,7 @@ impl FollowableItem for Editor {
 
     fn to_follow_event(event: &EditorEvent) -> Option<workspace::item::FollowEvent> {
         match event {
-            EditorEvent::Edited => Some(FollowEvent::Unfollow),
+            EditorEvent::Edited { .. } => Some(FollowEvent::Unfollow),
             EditorEvent::SelectionsChanged { local }
             | EditorEvent::ScrollPositionChanged { local, .. } => {
                 if *local {

crates/extensions_ui/src/extensions_ui.rs 🔗

@@ -773,7 +773,7 @@ impl ExtensionsPage {
         event: &editor::EditorEvent,
         cx: &mut ViewContext<Self>,
     ) {
-        if let editor::EditorEvent::Edited = event {
+        if let editor::EditorEvent::Edited { .. } = event {
             self.query_contains_error = false;
             self.fetch_extensions_debounced(cx);
         }

crates/feedback/src/feedback_modal.rs 🔗

@@ -193,7 +193,7 @@ impl FeedbackModal {
         });
 
         cx.subscribe(&feedback_editor, |this, editor, event: &EditorEvent, cx| {
-            if *event == EditorEvent::Edited {
+            if matches!(event, EditorEvent::Edited { .. }) {
                 this.character_count = editor
                     .read(cx)
                     .buffer()

crates/go_to_line/src/go_to_line.rs 🔗

@@ -42,17 +42,19 @@ enum GoToLineRowHighlights {}
 impl GoToLine {
     fn register(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
         let handle = cx.view().downgrade();
-        editor.register_action(move |_: &Toggle, cx| {
-            let Some(editor) = handle.upgrade() else {
-                return;
-            };
-            let Some(workspace) = editor.read(cx).workspace() else {
-                return;
-            };
-            workspace.update(cx, |workspace, cx| {
-                workspace.toggle_modal(cx, move |cx| GoToLine::new(editor, cx));
+        editor
+            .register_action(move |_: &Toggle, cx| {
+                let Some(editor) = handle.upgrade() else {
+                    return;
+                };
+                let Some(workspace) = editor.read(cx).workspace() else {
+                    return;
+                };
+                workspace.update(cx, |workspace, cx| {
+                    workspace.toggle_modal(cx, move |cx| GoToLine::new(editor, cx));
+                })
             })
-        });
+            .detach();
     }
 
     pub fn new(active_editor: View<Editor>, cx: &mut ViewContext<Self>) -> Self {

crates/gpui/src/subscription.rs 🔗

@@ -154,6 +154,14 @@ pub struct Subscription {
 }
 
 impl Subscription {
+    /// Creates a new subscription with a callback that gets invoked when
+    /// this subscription is dropped.
+    pub fn new(unsubscribe: impl 'static + FnOnce()) -> Self {
+        Self {
+            unsubscribe: Some(Box::new(unsubscribe)),
+        }
+    }
+
     /// Detaches the subscription from this handle. The callback will
     /// continue to be invoked until the views or models it has been
     /// subscribed to are dropped

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -294,7 +294,7 @@ impl MarkdownPreviewView {
 
         let subscription = cx.subscribe(&editor, |this, editor, event: &EditorEvent, cx| {
             match event {
-                EditorEvent::Edited => {
+                EditorEvent::Edited { .. } => {
                     this.parse_markdown_from_active_editor(true, cx);
                 }
                 EditorEvent::SelectionsChanged { .. } => {

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -789,6 +789,68 @@ impl MultiBuffer {
         }
     }
 
+    pub fn edited_ranges_for_transaction<D>(
+        &self,
+        transaction_id: TransactionId,
+        cx: &AppContext,
+    ) -> Vec<Range<D>>
+    where
+        D: TextDimension + Ord + Sub<D, Output = D>,
+    {
+        if let Some(buffer) = self.as_singleton() {
+            return buffer
+                .read(cx)
+                .edited_ranges_for_transaction_id(transaction_id)
+                .collect::<Vec<_>>();
+        }
+
+        let Some(transaction) = self.history.transaction(transaction_id) else {
+            return Vec::new();
+        };
+
+        let mut ranges = Vec::new();
+        let snapshot = self.read(cx);
+        let buffers = self.buffers.borrow();
+        let mut cursor = snapshot.excerpts.cursor::<ExcerptSummary>();
+
+        for (buffer_id, buffer_transaction) in &transaction.buffer_transactions {
+            let Some(buffer_state) = buffers.get(&buffer_id) else {
+                continue;
+            };
+
+            let buffer = buffer_state.buffer.read(cx);
+            for range in buffer.edited_ranges_for_transaction_id::<D>(*buffer_transaction) {
+                for excerpt_id in &buffer_state.excerpts {
+                    cursor.seek(excerpt_id, Bias::Left, &());
+                    if let Some(excerpt) = cursor.item() {
+                        if excerpt.locator == *excerpt_id {
+                            let excerpt_buffer_start =
+                                excerpt.range.context.start.summary::<D>(buffer);
+                            let excerpt_buffer_end = excerpt.range.context.end.summary::<D>(buffer);
+                            let excerpt_range = excerpt_buffer_start.clone()..excerpt_buffer_end;
+                            if excerpt_range.contains(&range.start)
+                                && excerpt_range.contains(&range.end)
+                            {
+                                let excerpt_start = D::from_text_summary(&cursor.start().text);
+
+                                let mut start = excerpt_start.clone();
+                                start.add_assign(&(range.start - excerpt_buffer_start.clone()));
+                                let mut end = excerpt_start;
+                                end.add_assign(&(range.end - excerpt_buffer_start));
+
+                                ranges.push(start..end);
+                                break;
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        ranges.sort_by_key(|range| range.start.clone());
+        ranges
+    }
+
     pub fn merge_transactions(
         &mut self,
         transaction: TransactionId,
@@ -3968,6 +4030,17 @@ impl History {
         }
     }
 
+    fn transaction(&self, transaction_id: TransactionId) -> Option<&Transaction> {
+        self.undo_stack
+            .iter()
+            .find(|transaction| transaction.id == transaction_id)
+            .or_else(|| {
+                self.redo_stack
+                    .iter()
+                    .find(|transaction| transaction.id == transaction_id)
+            })
+    }
+
     fn transaction_mut(&mut self, transaction_id: TransactionId) -> Option<&mut Transaction> {
         self.undo_stack
             .iter_mut()
@@ -6060,6 +6133,15 @@ mod tests {
             multibuffer.end_transaction_at(now, cx);
             assert_eq!(multibuffer.read(cx).text(), "AB1234\nAB5678");
 
+            // Verify edited ranges for transaction 1
+            assert_eq!(
+                multibuffer.edited_ranges_for_transaction(transaction_1, cx),
+                &[
+                    Point::new(0, 0)..Point::new(0, 2),
+                    Point::new(1, 0)..Point::new(1, 2)
+                ]
+            );
+
             // Edit buffer 1 through the multibuffer
             now += 2 * group_interval;
             multibuffer.start_transaction_at(now, cx);

crates/outline/src/outline.rs 🔗

@@ -68,11 +68,13 @@ impl OutlineView {
     fn register(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
         if editor.mode() == EditorMode::Full {
             let handle = cx.view().downgrade();
-            editor.register_action(move |action, cx| {
-                if let Some(editor) = handle.upgrade() {
-                    toggle(editor, action, cx);
-                }
-            });
+            editor
+                .register_action(move |action, cx| {
+                    if let Some(editor) = handle.upgrade() {
+                        toggle(editor, action, cx);
+                    }
+                })
+                .detach();
         }
     }
 

crates/search/src/buffer_search.rs 🔗

@@ -811,7 +811,7 @@ impl BufferSearchBar {
         match event {
             editor::EditorEvent::Focused => self.query_editor_focused = true,
             editor::EditorEvent::Blurred => self.query_editor_focused = false,
-            editor::EditorEvent::Edited => {
+            editor::EditorEvent::Edited { .. } => {
                 self.clear_matches(cx);
                 let search = self.update_matches(cx);
 

crates/text/src/text.rs 🔗

@@ -356,6 +356,19 @@ impl History {
         }
     }
 
+    fn transaction(&self, transaction_id: TransactionId) -> Option<&Transaction> {
+        let entry = self
+            .undo_stack
+            .iter()
+            .rfind(|entry| entry.transaction.id == transaction_id)
+            .or_else(|| {
+                self.redo_stack
+                    .iter()
+                    .rfind(|entry| entry.transaction.id == transaction_id)
+            })?;
+        Some(&entry.transaction)
+    }
+
     fn transaction_mut(&mut self, transaction_id: TransactionId) -> Option<&mut Transaction> {
         let entry = self
             .undo_stack
@@ -1389,6 +1402,19 @@ impl Buffer {
         self.history.finalize_last_transaction();
     }
 
+    pub fn edited_ranges_for_transaction_id<D>(
+        &self,
+        transaction_id: TransactionId,
+    ) -> impl '_ + Iterator<Item = Range<D>>
+    where
+        D: TextDimension,
+    {
+        self.history
+            .transaction(transaction_id)
+            .into_iter()
+            .flat_map(|transaction| self.edited_ranges_for_transaction(transaction))
+    }
+
     pub fn edited_ranges_for_transaction<'a, D>(
         &'a self,
         transaction: &'a Transaction,

crates/vim/src/vim.rs 🔗

@@ -271,7 +271,9 @@ impl Vim {
             EditorEvent::TransactionUndone { transaction_id } => Vim::update(cx, |vim, cx| {
                 vim.transaction_undone(transaction_id, cx);
             }),
-            EditorEvent::Edited => Vim::update(cx, |vim, cx| vim.transaction_ended(editor, cx)),
+            EditorEvent::Edited { .. } => {
+                Vim::update(cx, |vim, cx| vim.transaction_ended(editor, cx))
+            }
             _ => {}
         }));
 

crates/zed/src/zed/inline_completion_registry.rs 🔗

@@ -74,23 +74,30 @@ fn register_backward_compatible_actions(editor: &mut Editor, cx: &mut ViewContex
                 editor.show_inline_completion(&Default::default(), cx);
             },
         ))
+        .detach();
+    editor
         .register_action(cx.listener(
             |editor, _: &copilot::NextSuggestion, cx: &mut ViewContext<Editor>| {
                 editor.next_inline_completion(&Default::default(), cx);
             },
         ))
+        .detach();
+    editor
         .register_action(cx.listener(
             |editor, _: &copilot::PreviousSuggestion, cx: &mut ViewContext<Editor>| {
                 editor.previous_inline_completion(&Default::default(), cx);
             },
         ))
+        .detach();
+    editor
         .register_action(cx.listener(
             |editor,
              _: &editor::actions::AcceptPartialCopilotSuggestion,
              cx: &mut ViewContext<Editor>| {
                 editor.accept_partial_inline_completion(&Default::default(), cx);
             },
-        ));
+        ))
+        .detach();
 }
 
 fn assign_inline_completion_provider(