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}