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. Clear search highlights on edit (Insert/Delete events)
152 // This preserves highlights while navigating but clears them when modifying text
153 // EXCEPT during interactive replace where we want to keep highlights visible
154 let in_interactive_replace = self.active_window().interactive_replace_state.is_some();
155
156 // Note: We intentionally do NOT clear search overlays on buffer modification.
157 // Overlays have markers that automatically track position changes through edits,
158 // which allows F3/Shift+F3 to find matches at their updated positions.
159 // The visual highlights may be on text that no longer matches the query,
160 // but that's acceptable - user can see where original matches were.
161 let _ = in_interactive_replace; // silence unused warning
162
163 // 3. Trigger plugin hooks for this event (with pre-calculated line info)
164 self.trigger_plugin_hooks_for_event(event, line_info);
165
166 // 4. Notify LSP of the change using pre-calculated positions
167 // For BulkEdit events (undo/redo of code actions, renames, etc.),
168 // collect_lsp_changes returns empty because there are no incremental byte
169 // positions to convert — BulkEdit restores a tree snapshot. Send a
170 // full-document replacement so the LSP server stays in sync.
171 if lsp_changes.is_empty() && event.modifies_buffer() {
172 if let Some(full_text) = self.active_state().buffer.to_string() {
173 let full_change = vec![TextDocumentContentChangeEvent {
174 range: None,
175 range_length: None,
176 text: full_text,
177 }];
178 let buf = self.active_buffer();
179 self.active_window_mut()
180 .send_lsp_changes_for_buffer(buf, full_change);
181 }
182 } else {
183 let buf = self.active_buffer();
184 self.active_window_mut()
185 .send_lsp_changes_for_buffer(buf, lsp_changes);
186 }
187 }
188
189 /// Apply multiple Insert/Delete events efficiently using bulk edit optimization.
190 ///
191 /// This avoids O(n²) complexity by:
192 /// 1. Converting events to (position, delete_len, insert_text) tuples
193 /// 2. Applying all edits in a single tree pass via apply_bulk_edits
194 /// 3. Creating a BulkEdit event for undo (stores tree snapshot via Arc clone = O(1))
195 ///
196 /// # Arguments
197 /// * `events` - Vec of Insert/Delete events (sorted by position descending for correct application)
198 /// * `description` - Description for the undo log
199 ///
200 /// # Returns
201 /// The BulkEdit event that was applied, for tracking purposes
202 pub fn apply_events_as_bulk_edit(
203 &mut self,
204 events: Vec<Event>,
205 description: String,
206 ) -> Option<Event> {
207 use crate::model::event::CursorId;
208
209 // Check if any events modify the buffer
210 let has_buffer_mods = events
211 .iter()
212 .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
213
214 if !has_buffer_mods {
215 // No buffer modifications - use regular Batch
216 return None;
217 }
218
219 // Multi-cursor edits and code-action rewrites go through this path
220 // (not `apply_event_to_active_buffer`). Promote any preview tab
221 // here too so the invariant "edited buffer is never preview"
222 // holds regardless of which edit path runs.
223 self.active_window_mut()
224 .promote_active_buffer_from_preview();
225
226 let active_buf = self.active_buffer();
227 // Use `effective_active_split` rather than `split_manager.active_split()`
228 // so we get the leaf whose `SplitViewState` actually owns the active
229 // buffer's keyed_states. They diverge whenever a buffer-group panel
230 // is focused (e.g. the Theme Editor): `active_buffer()` resolves to
231 // the inner panel's buffer via `effective_active_pair`, while the
232 // outer split has no entry for it. Without this, a paste with >1
233 // event in the Theme Editor unwraps `None` and panics.
234 let split_id = self.effective_active_split();
235
236 // Capture old cursor states from SplitViewState (sole source of truth)
237 let old_cursors: Vec<(CursorId, usize, Option<usize>)> = self
238 .windows
239 .get(&self.active_window)
240 .and_then(|w| w.buffers.splits())
241 .map(|(_, vs)| vs)
242 .expect("active window must have a populated split layout")
243 .get(&split_id)
244 .unwrap()
245 .keyed_states
246 .get(&active_buf)
247 .unwrap()
248 .cursors
249 .iter()
250 .map(|(id, c)| (id, c.position, c.anchor))
251 .collect();
252
253 let state = self
254 .windows
255 .get_mut(&self.active_window)
256 .map(|w| &mut w.buffers)
257 .expect("active window present")
258 .get_mut(&active_buf)
259 .unwrap();
260
261 // Snapshot buffer state for undo (piece tree + buffers)
262 let old_snapshot = state.buffer.snapshot_buffer_state();
263
264 // Convert events to edit tuples: (position, delete_len, insert_text)
265 // Events must be sorted by position descending (later positions first)
266 // This ensures earlier edits don't shift positions of later edits
267 let mut edits: Vec<(usize, usize, String)> = Vec::new();
268
269 for event in &events {
270 match event {
271 Event::Insert { position, text, .. } => {
272 edits.push((*position, 0, text.clone()));
273 }
274 Event::Delete { range, .. } => {
275 edits.push((range.start, range.len(), String::new()));
276 }
277 _ => {}
278 }
279 }
280
281 // Sort edits by position descending (required by apply_bulk_edits)
282 edits.sort_by(|a, b| b.0.cmp(&a.0));
283
284 // Convert to references for apply_bulk_edits
285 let edit_refs: Vec<(usize, usize, &str)> = edits
286 .iter()
287 .map(|(pos, del, text)| (*pos, *del, text.as_str()))
288 .collect();
289
290 // Snapshot displaced markers before edits so undo can restore them exactly.
291 let displaced_markers = state.capture_displaced_markers_bulk(&edits);
292
293 // Apply bulk edits
294 let _delta = state.buffer.apply_bulk_edits(&edit_refs);
295
296 // Convert edit list to lengths-only for marker replay.
297 // Merge edits at the same position into a single (pos, del_len, ins_len)
298 // tuple. This is necessary because delete+insert at the same position
299 // (e.g., line move: delete block, insert rearranged block) should be
300 // treated as a replacement, not two independent adjustments.
301 let edit_lengths: Vec<(usize, usize, usize)> = {
302 let mut lengths: Vec<(usize, usize, usize)> = Vec::new();
303 for (pos, del_len, text) in &edits {
304 if let Some(last) = lengths.last_mut() {
305 if last.0 == *pos {
306 // Same position: merge del and ins lengths
307 last.1 += del_len;
308 last.2 += text.len();
309 continue;
310 }
311 }
312 lengths.push((*pos, *del_len, text.len()));
313 }
314 lengths
315 };
316
317 // Adjust markers and margins using the merged edit lengths.
318 // Using merged edits (net delta for same-position replacements) avoids
319 // the marker-at-boundary problem where sequential delete+insert at the
320 // same position pushes markers incorrectly.
321 for &(pos, del_len, ins_len) in &edit_lengths {
322 if del_len > 0 && ins_len > 0 {
323 // Replacement: adjust by net delta only
324 if ins_len > del_len {
325 state.marker_list.adjust_for_insert(pos, ins_len - del_len);
326 state.margins.adjust_for_insert(pos, ins_len - del_len);
327 } else if del_len > ins_len {
328 state.marker_list.adjust_for_delete(pos, del_len - ins_len);
329 state.margins.adjust_for_delete(pos, del_len - ins_len);
330 }
331 // Equal: net delta 0, no adjustment needed
332 } else if del_len > 0 {
333 state.marker_list.adjust_for_delete(pos, del_len);
334 state.margins.adjust_for_delete(pos, del_len);
335 } else if ins_len > 0 {
336 state.marker_list.adjust_for_insert(pos, ins_len);
337 state.margins.adjust_for_insert(pos, ins_len);
338 }
339 }
340
341 // Snapshot buffer state after edits (for redo)
342 let new_snapshot = state.buffer.snapshot_buffer_state();
343
344 // Calculate new cursor positions based on events
345 // Process cursor movements from the original events
346 let mut new_cursors: Vec<(CursorId, usize, Option<usize>)> = old_cursors.clone();
347
348 // Calculate position adjustments from edits (sorted ascending by position)
349 // Each entry is (edit_position, delta) where delta = insert_len - delete_len
350 let mut position_deltas: Vec<(usize, isize)> = Vec::new();
351 for (pos, del_len, text) in &edits {
352 let delta = text.len() as isize - *del_len as isize;
353 position_deltas.push((*pos, delta));
354 }
355 position_deltas.sort_by_key(|(pos, _)| *pos);
356
357 // Helper: calculate cumulative shift for a position based on edits at lower positions
358 let calc_shift = |original_pos: usize| -> isize {
359 let mut shift: isize = 0;
360 for (edit_pos, delta) in &position_deltas {
361 if *edit_pos < original_pos {
362 shift += delta;
363 }
364 }
365 shift
366 };
367
368 // Apply adjustments to cursor positions
369 // First check for explicit MoveCursor events (e.g., from indent operations)
370 // These take precedence over implicit cursor updates from Insert/Delete
371 for (cursor_id, ref mut pos, ref mut anchor) in &mut new_cursors {
372 let mut found_move_cursor = false;
373 // Save original position before any modifications - needed for shift calculation
374 let original_pos = *pos;
375
376 // Check if this cursor has an Insert at its original position (auto-close pattern).
377 // For auto-close, Insert is at cursor position and MoveCursor is relative to original state.
378 // For other operations (like indent), Insert is elsewhere and MoveCursor already accounts for shifts.
379 let insert_at_cursor_pos = events.iter().any(|e| {
380 matches!(e, Event::Insert { position, cursor_id: c, .. }
381 if *c == *cursor_id && *position == original_pos)
382 });
383
384 // First pass: look for explicit MoveCursor events for this cursor
385 for event in &events {
386 if let Event::MoveCursor {
387 cursor_id: event_cursor,
388 new_position,
389 new_anchor,
390 ..
391 } = event
392 {
393 if event_cursor == cursor_id {
394 // Only adjust for shifts if the Insert was at the cursor's original position
395 // (like auto-close). For other operations (like indent where Insert is at
396 // line start), the MoveCursor already accounts for the shift.
397 let shift = if insert_at_cursor_pos {
398 calc_shift(original_pos)
399 } else {
400 0
401 };
402 *pos = (*new_position as isize + shift).max(0) as usize;
403 *anchor = *new_anchor;
404 found_move_cursor = true;
405 }
406 }
407 }
408
409 // If no explicit MoveCursor, derive position from Insert/Delete
410 if !found_move_cursor {
411 let mut found_edit = false;
412 for event in &events {
413 match event {
414 Event::Insert {
415 position,
416 text,
417 cursor_id: event_cursor,
418 } if event_cursor == cursor_id => {
419 // For insert, cursor moves to end of inserted text
420 // Account for shifts from edits at lower positions
421 let shift = calc_shift(*position);
422 let adjusted_pos = (*position as isize + shift).max(0) as usize;
423 *pos = adjusted_pos.saturating_add(text.len());
424 *anchor = None;
425 found_edit = true;
426 }
427 Event::Delete {
428 range,
429 cursor_id: event_cursor,
430 ..
431 } if event_cursor == cursor_id => {
432 // For delete, cursor moves to start of deleted range
433 // Account for shifts from edits at lower positions
434 let shift = calc_shift(range.start);
435 *pos = (range.start as isize + shift).max(0) as usize;
436 *anchor = None;
437 found_edit = true;
438 }
439 _ => {}
440 }
441 }
442
443 // If this cursor had no events at all (e.g., cursor at end of buffer
444 // during Delete, or at start during Backspace), still adjust its position
445 // for shifts caused by other cursors' edits.
446 if !found_edit {
447 let shift = calc_shift(original_pos);
448 *pos = (original_pos as isize + shift).max(0) as usize;
449 }
450 }
451 }
452
453 // Update cursors in SplitViewState (sole source of truth)
454 {
455 let cursors = &mut self
456 .split_view_states_mut()
457 .get_mut(&split_id)
458 .unwrap()
459 .keyed_states
460 .get_mut(&active_buf)
461 .unwrap()
462 .cursors;
463 for (cursor_id, position, anchor) in &new_cursors {
464 if let Some(cursor) = cursors.get_mut(*cursor_id) {
465 cursor.position = *position;
466 cursor.anchor = *anchor;
467 }
468 }
469 }
470
471 // Notify the highlighter of each edit so the cache can take the
472 // partial-update path on the next render. Throwing the whole cache
473 // away here (the previous behaviour) wiped every checkpoint as well,
474 // forcing a cold reparse from byte zero on the next keystroke — see
475 // https://github.com/sinelaw/fresh/issues/1958.
476 self.windows
477 .get_mut(&self.active_window)
478 .map(|w| &mut w.buffers)
479 .expect("active window present")
480 .get_mut(&active_buf)
481 .unwrap()
482 .highlighter
483 .notify_edits(&edit_lengths);
484
485 // Create BulkEdit event with both buffer snapshots
486 let bulk_edit = Event::BulkEdit {
487 old_snapshot: Some(old_snapshot),
488 new_snapshot: Some(new_snapshot),
489 old_cursors,
490 new_cursors,
491 description,
492 edits: edit_lengths,
493 displaced_markers,
494 };
495
496 // Post-processing (layout invalidation, split cursor sync, etc.)
497 let buf = self.active_buffer();
498 let win = self.active_window_mut();
499 win.invalidate_layouts_for_buffer(buf);
500 win.adjust_other_split_cursors_for_event(&bulk_edit);
501 // Note: Do NOT clear search overlays - markers track through edits for F3/Shift+F3
502
503 // Notify LSP of the change using full document replacement.
504 // Bulk edits combine multiple Delete+Insert operations into a single tree pass,
505 // so computing individual incremental LSP changes is not feasible. Instead,
506 // send the full document content which is always correct.
507 let buffer_id = self.active_buffer();
508 let full_content_change = self
509 .buffers()
510 .get(&buffer_id)
511 .and_then(|s| s.buffer.to_string())
512 .map(|text| {
513 vec![TextDocumentContentChangeEvent {
514 range: None,
515 range_length: None,
516 text,
517 }]
518 })
519 .unwrap_or_default();
520 if !full_content_change.is_empty() {
521 self.active_window_mut()
522 .send_lsp_changes_for_buffer(buffer_id, full_content_change);
523 }
524
525 Some(bulk_edit)
526 }
527
528 /// Trigger plugin hooks for an event (if any)
529 /// line_info contains pre-calculated line numbers from BEFORE buffer modification
530 fn trigger_plugin_hooks_for_event(&mut self, event: &Event, line_info: EventLineInfo) {
531 let buffer_id = self.active_buffer();
532
533 // Convert event to hook args and fire the appropriate hook
534 let mut cursor_changed_lines = false;
535 let hook_args = match event {
536 Event::Insert { position, text, .. } => {
537 let insert_position = *position;
538 let insert_len = text.len();
539
540 // Adjust byte ranges for the insertion
541 if let Some(seen) = self
542 .active_window_mut()
543 .seen_byte_ranges
544 .get_mut(&buffer_id)
545 {
546 // Collect adjusted ranges:
547 // - Ranges ending before insert: keep unchanged
548 // - Ranges containing insert point: remove (content changed)
549 // - Ranges starting after insert: shift by insert_len
550 let adjusted: std::collections::HashSet<(usize, usize)> = seen
551 .iter()
552 .filter_map(|&(start, end)| {
553 if end <= insert_position {
554 // Range ends before insert - unchanged
555 Some((start, end))
556 } else if start >= insert_position {
557 // Range starts at or after insert - shift forward
558 Some((start + insert_len, end + insert_len))
559 } else {
560 // Range contains insert point - invalidate
561 None
562 }
563 })
564 .collect();
565 *seen = adjusted;
566 }
567
568 Some((
569 "after_insert",
570 crate::services::plugins::hooks::HookArgs::AfterInsert {
571 buffer_id,
572 position: *position,
573 text: text.clone(),
574 // Byte range of the affected area
575 affected_start: insert_position,
576 affected_end: insert_position + insert_len,
577 // Line info from pre-modification buffer
578 start_line: line_info.start_line,
579 end_line: line_info.end_line,
580 lines_added: line_info.line_delta.max(0) as usize,
581 },
582 ))
583 }
584 Event::Delete {
585 range,
586 deleted_text,
587 ..
588 } => {
589 let delete_start = range.start;
590
591 // Adjust byte ranges for the deletion
592 let delete_end = range.end;
593 let delete_len = delete_end - delete_start;
594 if let Some(seen) = self
595 .active_window_mut()
596 .seen_byte_ranges
597 .get_mut(&buffer_id)
598 {
599 // Collect adjusted ranges:
600 // - Ranges ending before delete start: keep unchanged
601 // - Ranges overlapping deletion: remove (content changed)
602 // - Ranges starting after delete end: shift backward by delete_len
603 let adjusted: std::collections::HashSet<(usize, usize)> = seen
604 .iter()
605 .filter_map(|&(start, end)| {
606 if end <= delete_start {
607 // Range ends before delete - unchanged
608 Some((start, end))
609 } else if start >= delete_end {
610 // Range starts after delete - shift backward
611 Some((start - delete_len, end - delete_len))
612 } else {
613 // Range overlaps deletion - invalidate
614 None
615 }
616 })
617 .collect();
618 *seen = adjusted;
619 }
620
621 Some((
622 "after_delete",
623 crate::services::plugins::hooks::HookArgs::AfterDelete {
624 buffer_id,
625 start: range.start,
626 end: range.end,
627 deleted_text: deleted_text.clone(),
628 // Byte position and length of deleted content
629 affected_start: delete_start,
630 deleted_len: deleted_text.len(),
631 // Line info from pre-modification buffer
632 start_line: line_info.start_line,
633 end_line: line_info.end_line,
634 lines_removed: (-line_info.line_delta).max(0) as usize,
635 },
636 ))
637 }
638 Event::Batch { events, .. } => {
639 // Fire hooks for each event in the batch
640 // Note: For batches, line info is approximate since buffer already modified
641 // Individual events will use the passed line_info which covers the whole batch
642 for e in events {
643 // Use default line info for sub-events - they share the batch's line_info
644 // This is a simplification; proper tracking would need per-event pre-calculation
645 let sub_line_info = self.active_window().calculate_event_line_info(e);
646 self.trigger_plugin_hooks_for_event(e, sub_line_info);
647 }
648 None
649 }
650 Event::MoveCursor {
651 cursor_id,
652 old_position,
653 new_position,
654 ..
655 } => {
656 // Get line numbers for old and new positions (1-indexed for plugins)
657 let old_line = self.active_state().buffer.get_line_number(*old_position) + 1;
658 let line = self.active_state().buffer.get_line_number(*new_position) + 1;
659 cursor_changed_lines = old_line != line;
660 let text_props = self
661 .active_state()
662 .text_properties
663 .get_at(*new_position)
664 .into_iter()
665 .map(|tp| tp.properties.clone())
666 .collect();
667 Some((
668 "cursor_moved",
669 crate::services::plugins::hooks::HookArgs::CursorMoved {
670 buffer_id,
671 cursor_id: *cursor_id,
672 old_position: *old_position,
673 new_position: *new_position,
674 line,
675 text_properties: text_props,
676 },
677 ))
678 }
679 _ => None,
680 };
681
682 // Fire the hook to TypeScript plugins
683 if let Some((hook_name, ref args)) = hook_args {
684 // Update the full plugin state snapshot BEFORE firing the hook
685 // This ensures the plugin can read up-to-date state (diff, cursors, viewport, etc.)
686 // Without this, there's a race condition where the async hook might read stale data
687 #[cfg(feature = "plugins")]
688 self.update_plugin_state_snapshot();
689
690 self.plugin_manager
691 .read()
692 .unwrap()
693 .run_hook(hook_name, args.clone());
694 }
695
696 // After inter-line cursor_moved, proactively refresh lines so
697 // cursor-dependent conceals (e.g. emphasis auto-expose in compose
698 // mode tables) update in the same frame. Without this, there's a
699 // one-frame lag: the cursor_moved hook fires async to the plugin
700 // which calls refreshLines() back, but that round-trip means the
701 // first render after the cursor move still shows stale conceals.
702 //
703 // Only refresh on inter-line movement: intra-line moves (e.g.
704 // Left/Right within a row) don't change which row is auto-exposed,
705 // and the plugin's async refreshLines() handles span-level changes.
706 if cursor_changed_lines {
707 self.handle_refresh_lines(buffer_id);
708 }
709 }
710}