Skip to main content

fresh/app/
event_apply.rs

1//! Event application orchestrators on `Editor`.
2//!
3//! Every buffer mutation in this editor flows through one of:
4//!
5//! - `log_and_apply_event` — the canonical single-event path that logs
6//!   to the EventLog and applies the event.
7//! - `apply_event_to_active_buffer` — apply without logging, used by
8//!   replay paths.
9//! - `apply_events_as_bulk_edit` — batched multi-event application
10//!   under one undo boundary, used by replace-all, format-on-save, etc.
11//! - `trigger_plugin_hooks_for_event` — broadcast hook notifications
12//!   to plugins after an event applies.
13//!
14//! The "scroll/viewport event" handlers (handle_scroll_event,
15//! handle_set_viewport_event, handle_recenter_event) and the small
16//! `invalidate_layouts_for_buffer` helper now live on `impl Window`
17//! since they're entirely per-window concerns.
18
19use lsp_types::TextDocumentContentChangeEvent;
20
21use crate::model::event::Event;
22
23use super::types::EventLineInfo;
24use super::Editor;
25
26impl Editor {
27    /// All event applications MUST go through this method to ensure consistency.
28    /// Log an event and apply it to the active buffer.
29    /// For Delete events, captures displaced marker positions before applying
30    /// so undo can restore them to their exact original positions.
31    pub fn log_and_apply_event(&mut self, event: &Event) {
32        // Capture displaced markers before the event is applied
33        if let Event::Delete { range, .. } = event {
34            let displaced = self.active_state().capture_displaced_markers(range);
35            self.active_event_log_mut().append(event.clone());
36            if !displaced.is_empty() {
37                self.active_event_log_mut()
38                    .set_displaced_markers_on_last(displaced);
39            }
40        } else {
41            self.active_event_log_mut().append(event.clone());
42        }
43        self.apply_event_to_active_buffer(event);
44    }
45
46    pub fn apply_event_to_active_buffer(&mut self, event: &Event) {
47        // Handle View events at Editor level - View events go to SplitViewState, not EditorState
48        // This properly separates Buffer state from View state
49        match event {
50            Event::Scroll { line_offset } => {
51                self.active_window_mut().handle_scroll_event(*line_offset);
52                return;
53            }
54            Event::SetViewport { top_line } => {
55                self.active_window_mut()
56                    .handle_set_viewport_event(*top_line);
57                return;
58            }
59            Event::Recenter => {
60                self.active_window_mut().handle_recenter_event();
61                return;
62            }
63            _ => {}
64        }
65
66        // Any buffer-modifying event commits the user to this file, so promote
67        // it out of preview mode. Cursor moves and view-only events don't
68        // count — only real edits (Insert / Delete / BulkEdit, or a Batch
69        // containing any of those) flip the bit. Placed here (rather than
70        // in `log_and_apply_event`) because several edit paths bypass
71        // logging and call `apply_event_to_active_buffer` directly — notably
72        // `InsertChar` (single-character typing).
73        if event.modifies_buffer() {
74            self.active_window_mut()
75                .promote_active_buffer_from_preview();
76        }
77
78        // IMPORTANT: Calculate LSP changes and line info BEFORE applying to buffer!
79        // The byte positions in the events are relative to the ORIGINAL buffer,
80        // so we must convert them to LSP positions before modifying the buffer.
81        let lsp_changes = self.active_window().collect_lsp_changes(event);
82
83        // Calculate line info for plugin hooks (using same pre-modification buffer state)
84        let line_info = self.active_window().calculate_event_line_info(event);
85
86        // 1. Apply the event to the buffer
87        // Borrow cursors from SplitViewState (sole source of truth) and state from buffers.
88        //
89        // Use the *effective* active split so events targeting a focused
90        // buffer-group panel land in the panel's own split view state, not
91        // the group host's. Without this, MoveCursor events for a focused
92        // panel would try to look up the panel buffer's keyed state in the
93        // host split (which doesn't have it) and panic on unwrap.
94        //
95        // Debug-only check: verify the pane-buffer invariant before
96        // dereferencing. Any mismatch means a write path skipped
97        // `Editor::set_pane_buffer` (see `active_focus.rs`); we want
98        // that to fail with a clear message in tests rather than
99        // surfacing as a bare `Option::unwrap` panic in production
100        // (issue #1620).
101        {
102            let split_id = self.effective_active_split();
103            let active_buf = self.active_buffer();
104            debug_assert!(
105                self.windows
106                    .get(&self.active_window)
107                    .and_then(|w| w.buffers.splits())
108                    .map(|(_, vs)| vs)
109                    .expect("active window must have a populated split layout")
110                    .get(&split_id)
111                    .is_some_and(|vs| vs.keyed_states.contains_key(&active_buf)),
112                "pane-buffer invariant violated: split {:?} resolves to buffer {:?} \
113                 but that split's keyed_states has no entry for it. Some write path \
114                 bypassed Editor::set_pane_buffer; see active_focus.rs / issue #1620.",
115                split_id,
116                active_buf,
117            );
118            self.active_window_mut()
119                .apply_event_to_keyed_buffer(active_buf, split_id, event);
120        }
121
122        // 1c. Invalidate layouts for all views of this buffer after content changes
123        // Note: recovery_pending is set automatically by the buffer on edits
124        match event {
125            Event::Insert { .. } | Event::Delete { .. } | Event::BulkEdit { .. } => {
126                let buf = self.active_buffer();
127                let win = self.active_window_mut();
128                win.invalidate_layouts_for_buffer(buf);
129                win.schedule_semantic_tokens_full_refresh(buf);
130                win.schedule_folding_ranges_refresh(buf);
131            }
132            Event::Batch { events, .. } => {
133                let has_edits = events
134                    .iter()
135                    .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
136                if has_edits {
137                    let buf = self.active_buffer();
138                    let win = self.active_window_mut();
139                    win.invalidate_layouts_for_buffer(buf);
140                    win.schedule_semantic_tokens_full_refresh(buf);
141                    win.schedule_folding_ranges_refresh(buf);
142                }
143            }
144            _ => {}
145        }
146
147        // 2. Adjust cursors in other splits that share the same buffer
148        self.active_window_mut()
149            .adjust_other_split_cursors_for_event(event);
150
151        // 3. Re-evaluate search highlights around the edited region.
152        // Overlays have markers that automatically track position changes
153        // through edits (so F3/Shift+F3 find matches at their updated
154        // positions), but the markers never re-check whether the covered
155        // text still matches the query. Without this, editing inside a
156        // highlighted match — or typing against its boundary so a
157        // whole-word `\b` rule no longer holds — would leave a stale
158        // highlight on text that no longer matches. We skip during
159        // interactive replace, which manages its own highlight lifecycle.
160        if self.active_window().interactive_replace_state.is_none() {
161            let search_bg = self.theme.read().unwrap().search_match_bg;
162            let search_fg = self.theme.read().unwrap().search_match_fg;
163            match event {
164                Event::Insert { position, text, .. } => {
165                    self.active_window_mut().reevaluate_search_overlays_around(
166                        *position,
167                        text.len(),
168                        search_fg,
169                        search_bg,
170                    );
171                }
172                Event::Delete { range, .. } => {
173                    self.active_window_mut().reevaluate_search_overlays_around(
174                        range.start,
175                        0,
176                        search_fg,
177                        search_bg,
178                    );
179                }
180                Event::Batch { events, .. } => {
181                    for e in events {
182                        match e {
183                            Event::Insert { position, text, .. } => {
184                                self.active_window_mut().reevaluate_search_overlays_around(
185                                    *position,
186                                    text.len(),
187                                    search_fg,
188                                    search_bg,
189                                );
190                            }
191                            Event::Delete { range, .. } => {
192                                self.active_window_mut().reevaluate_search_overlays_around(
193                                    range.start,
194                                    0,
195                                    search_fg,
196                                    search_bg,
197                                );
198                            }
199                            _ => {}
200                        }
201                    }
202                }
203                _ => {}
204            }
205        }
206
207        // 3. Trigger plugin hooks for this event (with pre-calculated line info)
208        self.trigger_plugin_hooks_for_event(event, line_info);
209
210        // 4. Notify LSP of the change using pre-calculated positions
211        // For BulkEdit events (undo/redo of code actions, renames, etc.),
212        // collect_lsp_changes returns empty because there are no incremental byte
213        // positions to convert — BulkEdit restores a tree snapshot.  Send a
214        // full-document replacement so the LSP server stays in sync.
215        if lsp_changes.is_empty() && event.modifies_buffer() {
216            if let Some(full_text) = self.active_state().buffer.to_string() {
217                let full_change = vec![TextDocumentContentChangeEvent {
218                    range: None,
219                    range_length: None,
220                    text: full_text,
221                }];
222                let buf = self.active_buffer();
223                self.active_window_mut()
224                    .send_lsp_changes_for_buffer(buf, full_change);
225            }
226        } else {
227            let buf = self.active_buffer();
228            self.active_window_mut()
229                .send_lsp_changes_for_buffer(buf, lsp_changes);
230        }
231    }
232
233    /// Apply multiple Insert/Delete events efficiently using bulk edit optimization.
234    ///
235    /// This avoids O(n²) complexity by:
236    /// 1. Converting events to (position, delete_len, insert_text) tuples
237    /// 2. Applying all edits in a single tree pass via apply_bulk_edits
238    /// 3. Creating a BulkEdit event for undo (stores tree snapshot via Arc clone = O(1))
239    ///
240    /// # Arguments
241    /// * `events` - Vec of Insert/Delete events (sorted by position descending for correct application)
242    /// * `description` - Description for the undo log
243    ///
244    /// # Returns
245    /// The BulkEdit event that was applied, for tracking purposes
246    pub fn apply_events_as_bulk_edit(
247        &mut self,
248        events: Vec<Event>,
249        description: String,
250    ) -> Option<Event> {
251        use crate::model::event::CursorId;
252
253        // Check if any events modify the buffer
254        let has_buffer_mods = events
255            .iter()
256            .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
257
258        if !has_buffer_mods {
259            // No buffer modifications - use regular Batch
260            return None;
261        }
262
263        // Multi-cursor edits and code-action rewrites go through this path
264        // (not `apply_event_to_active_buffer`). Promote any preview tab
265        // here too so the invariant "edited buffer is never preview"
266        // holds regardless of which edit path runs.
267        self.active_window_mut()
268            .promote_active_buffer_from_preview();
269
270        let active_buf = self.active_buffer();
271        // Use `effective_active_split` rather than `split_manager.active_split()`
272        // so we get the leaf whose `SplitViewState` actually owns the active
273        // buffer's keyed_states. They diverge whenever a buffer-group panel
274        // is focused (e.g. the Theme Editor): `active_buffer()` resolves to
275        // the inner panel's buffer via `effective_active_pair`, while the
276        // outer split has no entry for it. Without this, a paste with >1
277        // event in the Theme Editor unwraps `None` and panics.
278        let split_id = self.effective_active_split();
279
280        // Capture old cursor states from SplitViewState (sole source of truth)
281        let old_cursors: Vec<(CursorId, usize, Option<usize>)> = self
282            .windows
283            .get(&self.active_window)
284            .and_then(|w| w.buffers.splits())
285            .map(|(_, vs)| vs)
286            .expect("active window must have a populated split layout")
287            .get(&split_id)
288            .unwrap()
289            .keyed_states
290            .get(&active_buf)
291            .unwrap()
292            .cursors
293            .iter()
294            .map(|(id, c)| (id, c.position, c.anchor))
295            .collect();
296
297        let state = self
298            .windows
299            .get_mut(&self.active_window)
300            .map(|w| &mut w.buffers)
301            .expect("active window present")
302            .get_mut(&active_buf)
303            .unwrap();
304
305        // Snapshot buffer state for undo (piece tree + buffers)
306        let old_snapshot = state.buffer.snapshot_buffer_state();
307
308        // Convert events to edit tuples: (position, delete_len, insert_text)
309        // Events must be sorted by position descending (later positions first)
310        // This ensures earlier edits don't shift positions of later edits
311        let mut edits: Vec<(usize, usize, String)> = Vec::new();
312
313        for event in &events {
314            match event {
315                Event::Insert { position, text, .. } => {
316                    edits.push((*position, 0, text.clone()));
317                }
318                Event::Delete { range, .. } => {
319                    edits.push((range.start, range.len(), String::new()));
320                }
321                _ => {}
322            }
323        }
324
325        // Sort edits by position descending (required by apply_bulk_edits)
326        edits.sort_by(|a, b| b.0.cmp(&a.0));
327
328        // Convert to references for apply_bulk_edits
329        let edit_refs: Vec<(usize, usize, &str)> = edits
330            .iter()
331            .map(|(pos, del, text)| (*pos, *del, text.as_str()))
332            .collect();
333
334        // Snapshot displaced markers before edits so undo can restore them exactly.
335        let displaced_markers = state.capture_displaced_markers_bulk(&edits);
336
337        // Apply bulk edits
338        let _delta = state.buffer.apply_bulk_edits(&edit_refs);
339
340        // Convert edit list to lengths-only for marker replay.
341        // Merge edits at the same position into a single (pos, del_len, ins_len)
342        // tuple. This is necessary because delete+insert at the same position
343        // (e.g., line move: delete block, insert rearranged block) should be
344        // treated as a replacement, not two independent adjustments.
345        let edit_lengths: Vec<(usize, usize, usize)> = {
346            let mut lengths: Vec<(usize, usize, usize)> = Vec::new();
347            for (pos, del_len, text) in &edits {
348                if let Some(last) = lengths.last_mut() {
349                    if last.0 == *pos {
350                        // Same position: merge del and ins lengths
351                        last.1 += del_len;
352                        last.2 += text.len();
353                        continue;
354                    }
355                }
356                lengths.push((*pos, *del_len, text.len()));
357            }
358            lengths
359        };
360
361        // Adjust markers and margins using the merged edit lengths.
362        // Using merged edits (net delta for same-position replacements) avoids
363        // the marker-at-boundary problem where sequential delete+insert at the
364        // same position pushes markers incorrectly.
365        for &(pos, del_len, ins_len) in &edit_lengths {
366            if del_len > 0 && ins_len > 0 {
367                // Replacement: adjust by net delta only
368                if ins_len > del_len {
369                    state.marker_list.adjust_for_insert(pos, ins_len - del_len);
370                    state.margins.adjust_for_insert(pos, ins_len - del_len);
371                } else if del_len > ins_len {
372                    state.marker_list.adjust_for_delete(pos, del_len - ins_len);
373                    state.margins.adjust_for_delete(pos, del_len - ins_len);
374                }
375                // Equal: net delta 0, no adjustment needed
376            } else if del_len > 0 {
377                state.marker_list.adjust_for_delete(pos, del_len);
378                state.margins.adjust_for_delete(pos, del_len);
379            } else if ins_len > 0 {
380                state.marker_list.adjust_for_insert(pos, ins_len);
381                state.margins.adjust_for_insert(pos, ins_len);
382            }
383        }
384
385        // Snapshot buffer state after edits (for redo)
386        let new_snapshot = state.buffer.snapshot_buffer_state();
387
388        // Calculate new cursor positions based on events
389        // Process cursor movements from the original events
390        let mut new_cursors: Vec<(CursorId, usize, Option<usize>)> = old_cursors.clone();
391
392        // Calculate position adjustments from edits (sorted ascending by position)
393        // Each entry is (edit_position, delta) where delta = insert_len - delete_len
394        let mut position_deltas: Vec<(usize, isize)> = Vec::new();
395        for (pos, del_len, text) in &edits {
396            let delta = text.len() as isize - *del_len as isize;
397            position_deltas.push((*pos, delta));
398        }
399        position_deltas.sort_by_key(|(pos, _)| *pos);
400
401        // Helper: calculate cumulative shift for a position based on edits at lower positions
402        let calc_shift = |original_pos: usize| -> isize {
403            let mut shift: isize = 0;
404            for (edit_pos, delta) in &position_deltas {
405                if *edit_pos < original_pos {
406                    shift += delta;
407                }
408            }
409            shift
410        };
411
412        // Apply adjustments to cursor positions
413        // First check for explicit MoveCursor events (e.g., from indent operations)
414        // These take precedence over implicit cursor updates from Insert/Delete
415        for (cursor_id, ref mut pos, ref mut anchor) in &mut new_cursors {
416            let mut found_move_cursor = false;
417            // Save original position before any modifications - needed for shift calculation
418            let original_pos = *pos;
419
420            // Check if this cursor has an Insert at its original position (auto-close pattern).
421            // For auto-close, Insert is at cursor position and MoveCursor is relative to original state.
422            // For other operations (like indent), Insert is elsewhere and MoveCursor already accounts for shifts.
423            let insert_at_cursor_pos = events.iter().any(|e| {
424                matches!(e, Event::Insert { position, cursor_id: c, .. }
425                    if *c == *cursor_id && *position == original_pos)
426            });
427
428            // First pass: look for explicit MoveCursor events for this cursor
429            for event in &events {
430                if let Event::MoveCursor {
431                    cursor_id: event_cursor,
432                    new_position,
433                    new_anchor,
434                    ..
435                } = event
436                {
437                    if event_cursor == cursor_id {
438                        // Only adjust for shifts if the Insert was at the cursor's original position
439                        // (like auto-close). For other operations (like indent where Insert is at
440                        // line start), the MoveCursor already accounts for the shift.
441                        let shift = if insert_at_cursor_pos {
442                            calc_shift(original_pos)
443                        } else {
444                            0
445                        };
446                        *pos = (*new_position as isize + shift).max(0) as usize;
447                        *anchor = *new_anchor;
448                        found_move_cursor = true;
449                    }
450                }
451            }
452
453            // If no explicit MoveCursor, derive position from Insert/Delete
454            if !found_move_cursor {
455                let mut found_edit = false;
456                for event in &events {
457                    match event {
458                        Event::Insert {
459                            position,
460                            text,
461                            cursor_id: event_cursor,
462                        } if event_cursor == cursor_id => {
463                            // For insert, cursor moves to end of inserted text
464                            // Account for shifts from edits at lower positions
465                            let shift = calc_shift(*position);
466                            let adjusted_pos = (*position as isize + shift).max(0) as usize;
467                            *pos = adjusted_pos.saturating_add(text.len());
468                            *anchor = None;
469                            found_edit = true;
470                        }
471                        Event::Delete {
472                            range,
473                            cursor_id: event_cursor,
474                            ..
475                        } if event_cursor == cursor_id => {
476                            // For delete, cursor moves to start of deleted range
477                            // Account for shifts from edits at lower positions
478                            let shift = calc_shift(range.start);
479                            *pos = (range.start as isize + shift).max(0) as usize;
480                            *anchor = None;
481                            found_edit = true;
482                        }
483                        _ => {}
484                    }
485                }
486
487                // If this cursor had no events at all (e.g., cursor at end of buffer
488                // during Delete, or at start during Backspace), still adjust its position
489                // for shifts caused by other cursors' edits.
490                if !found_edit {
491                    let shift = calc_shift(original_pos);
492                    *pos = (original_pos as isize + shift).max(0) as usize;
493                }
494            }
495        }
496
497        // Update cursors in SplitViewState (sole source of truth)
498        {
499            let cursors = &mut self
500                .split_view_states_mut()
501                .get_mut(&split_id)
502                .unwrap()
503                .keyed_states
504                .get_mut(&active_buf)
505                .unwrap()
506                .cursors;
507            for (cursor_id, position, anchor) in &new_cursors {
508                if let Some(cursor) = cursors.get_mut(*cursor_id) {
509                    cursor.position = *position;
510                    cursor.anchor = *anchor;
511                }
512            }
513        }
514
515        // Notify the highlighter of each edit so the cache can take the
516        // partial-update path on the next render. Throwing the whole cache
517        // away here (the previous behaviour) wiped every checkpoint as well,
518        // forcing a cold reparse from byte zero on the next keystroke — see
519        // https://github.com/sinelaw/fresh/issues/1958.
520        self.windows
521            .get_mut(&self.active_window)
522            .map(|w| &mut w.buffers)
523            .expect("active window present")
524            .get_mut(&active_buf)
525            .unwrap()
526            .highlighter
527            .notify_edits(&edit_lengths);
528
529        // Create BulkEdit event with both buffer snapshots
530        let bulk_edit = Event::BulkEdit {
531            old_snapshot: Some(old_snapshot),
532            new_snapshot: Some(new_snapshot),
533            old_cursors,
534            new_cursors,
535            description,
536            edits: edit_lengths,
537            displaced_markers,
538        };
539
540        // Post-processing (layout invalidation, split cursor sync, etc.)
541        let buf = self.active_buffer();
542        let win = self.active_window_mut();
543        win.invalidate_layouts_for_buffer(buf);
544        win.adjust_other_split_cursors_for_event(&bulk_edit);
545        // Note: Do NOT clear search overlays - markers track through edits for F3/Shift+F3
546
547        // Notify LSP of the change using full document replacement.
548        // Bulk edits combine multiple Delete+Insert operations into a single tree pass,
549        // so computing individual incremental LSP changes is not feasible. Instead,
550        // send the full document content which is always correct.
551        let buffer_id = self.active_buffer();
552        let full_content_change = self
553            .buffers()
554            .get(&buffer_id)
555            .and_then(|s| s.buffer.to_string())
556            .map(|text| {
557                vec![TextDocumentContentChangeEvent {
558                    range: None,
559                    range_length: None,
560                    text,
561                }]
562            })
563            .unwrap_or_default();
564        if !full_content_change.is_empty() {
565            self.active_window_mut()
566                .send_lsp_changes_for_buffer(buffer_id, full_content_change);
567        }
568
569        Some(bulk_edit)
570    }
571
572    /// Trigger plugin hooks for an event (if any)
573    /// line_info contains pre-calculated line numbers from BEFORE buffer modification
574    fn trigger_plugin_hooks_for_event(&mut self, event: &Event, line_info: EventLineInfo) {
575        let buffer_id = self.active_buffer();
576
577        // Convert event to hook args and fire the appropriate hook
578        let mut cursor_changed_lines = false;
579        let hook_args = match event {
580            Event::Insert { position, text, .. } => {
581                let insert_position = *position;
582                let insert_len = text.len();
583
584                // Adjust byte ranges for the insertion
585                if let Some(seen) = self
586                    .active_window_mut()
587                    .seen_byte_ranges
588                    .get_mut(&buffer_id)
589                {
590                    // Collect adjusted ranges:
591                    // - Ranges ending before insert: keep unchanged
592                    // - Ranges containing insert point: remove (content changed)
593                    // - Ranges starting after insert: shift by insert_len
594                    let adjusted: std::collections::HashSet<(usize, usize)> = seen
595                        .iter()
596                        .filter_map(|&(start, end)| {
597                            if end <= insert_position {
598                                // Range ends before insert - unchanged
599                                Some((start, end))
600                            } else if start >= insert_position {
601                                // Range starts at or after insert - shift forward
602                                Some((start + insert_len, end + insert_len))
603                            } else {
604                                // Range contains insert point - invalidate
605                                None
606                            }
607                        })
608                        .collect();
609                    *seen = adjusted;
610                }
611
612                Some((
613                    "after_insert",
614                    crate::services::plugins::hooks::HookArgs::AfterInsert {
615                        buffer_id,
616                        position: *position,
617                        text: text.clone(),
618                        // Byte range of the affected area
619                        affected_start: insert_position,
620                        affected_end: insert_position + insert_len,
621                        // Line info from pre-modification buffer
622                        start_line: line_info.start_line,
623                        end_line: line_info.end_line,
624                        lines_added: line_info.line_delta.max(0) as usize,
625                    },
626                ))
627            }
628            Event::Delete {
629                range,
630                deleted_text,
631                ..
632            } => {
633                let delete_start = range.start;
634
635                // Adjust byte ranges for the deletion
636                let delete_end = range.end;
637                let delete_len = delete_end - delete_start;
638                if let Some(seen) = self
639                    .active_window_mut()
640                    .seen_byte_ranges
641                    .get_mut(&buffer_id)
642                {
643                    // Collect adjusted ranges:
644                    // - Ranges ending before delete start: keep unchanged
645                    // - Ranges overlapping deletion: remove (content changed)
646                    // - Ranges starting after delete end: shift backward by delete_len
647                    let adjusted: std::collections::HashSet<(usize, usize)> = seen
648                        .iter()
649                        .filter_map(|&(start, end)| {
650                            if end <= delete_start {
651                                // Range ends before delete - unchanged
652                                Some((start, end))
653                            } else if start >= delete_end {
654                                // Range starts after delete - shift backward
655                                Some((start - delete_len, end - delete_len))
656                            } else {
657                                // Range overlaps deletion - invalidate
658                                None
659                            }
660                        })
661                        .collect();
662                    *seen = adjusted;
663                }
664
665                Some((
666                    "after_delete",
667                    crate::services::plugins::hooks::HookArgs::AfterDelete {
668                        buffer_id,
669                        start: range.start,
670                        end: range.end,
671                        deleted_text: deleted_text.clone(),
672                        // Byte position and length of deleted content
673                        affected_start: delete_start,
674                        deleted_len: deleted_text.len(),
675                        // Line info from pre-modification buffer
676                        start_line: line_info.start_line,
677                        end_line: line_info.end_line,
678                        lines_removed: (-line_info.line_delta).max(0) as usize,
679                    },
680                ))
681            }
682            Event::Batch { events, .. } => {
683                // Fire hooks for each event in the batch
684                // Note: For batches, line info is approximate since buffer already modified
685                // Individual events will use the passed line_info which covers the whole batch
686                for e in events {
687                    // Use default line info for sub-events - they share the batch's line_info
688                    // This is a simplification; proper tracking would need per-event pre-calculation
689                    let sub_line_info = self.active_window().calculate_event_line_info(e);
690                    self.trigger_plugin_hooks_for_event(e, sub_line_info);
691                }
692                None
693            }
694            Event::MoveCursor {
695                cursor_id,
696                old_position,
697                new_position,
698                ..
699            } => {
700                // Get line numbers for old and new positions (1-indexed for plugins)
701                let old_line = self.active_state().buffer.get_line_number(*old_position) + 1;
702                let line = self.active_state().buffer.get_line_number(*new_position) + 1;
703                cursor_changed_lines = old_line != line;
704                let text_props = self
705                    .active_state()
706                    .text_properties
707                    .get_at(*new_position)
708                    .into_iter()
709                    .map(|tp| tp.properties.clone())
710                    .collect();
711                Some((
712                    "cursor_moved",
713                    crate::services::plugins::hooks::HookArgs::CursorMoved {
714                        buffer_id,
715                        cursor_id: *cursor_id,
716                        old_position: *old_position,
717                        new_position: *new_position,
718                        line,
719                        text_properties: text_props,
720                    },
721                ))
722            }
723            _ => None,
724        };
725
726        // Fire the hook to TypeScript plugins
727        if let Some((hook_name, ref args)) = hook_args {
728            // Update the full plugin state snapshot BEFORE firing the hook
729            // This ensures the plugin can read up-to-date state (diff, cursors, viewport, etc.)
730            // Without this, there's a race condition where the async hook might read stale data
731            #[cfg(feature = "plugins")]
732            self.update_plugin_state_snapshot();
733
734            self.plugin_manager
735                .read()
736                .unwrap()
737                .run_hook(hook_name, args.clone());
738        }
739
740        // After inter-line cursor_moved, proactively refresh lines so
741        // cursor-dependent conceals (e.g. emphasis auto-expose in compose
742        // mode tables) update in the same frame. Without this, there's a
743        // one-frame lag: the cursor_moved hook fires async to the plugin
744        // which calls refreshLines() back, but that round-trip means the
745        // first render after the cursor move still shows stale conceals.
746        //
747        // Only refresh on inter-line movement: intra-line moves (e.g.
748        // Left/Right within a row) don't change which row is auto-exposed,
749        // and the plugin's async refreshLines() handles span-level changes.
750        if cursor_changed_lines {
751            self.handle_refresh_lines(buffer_id);
752        }
753    }
754}