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//! Plus three "scroll/viewport event" handlers that bypass the buffer
15//! entirely: handle_scroll_event, handle_set_viewport_event,
16//! handle_recenter_event. And a small invalidate_layouts_for_buffer
17//! helper.
18
19use anyhow::Result as AnyhowResult;
20
21use lsp_types::{Position, Range as LspRange, TextDocumentContentChangeEvent};
22
23use crate::model::event::{BufferId, Event, LeafId};
24
25use super::types::EventLineInfo;
26use super::Editor;
27
28impl Editor {
29    /// All event applications MUST go through this method to ensure consistency.
30    /// Log an event and apply it to the active buffer.
31    /// For Delete events, captures displaced marker positions before applying
32    /// so undo can restore them to their exact original positions.
33    pub fn log_and_apply_event(&mut self, event: &Event) {
34        // Capture displaced markers before the event is applied
35        if let Event::Delete { range, .. } = event {
36            let displaced = self.active_state().capture_displaced_markers(range);
37            self.active_event_log_mut().append(event.clone());
38            if !displaced.is_empty() {
39                self.active_event_log_mut()
40                    .set_displaced_markers_on_last(displaced);
41            }
42        } else {
43            self.active_event_log_mut().append(event.clone());
44        }
45        self.apply_event_to_active_buffer(event);
46    }
47
48    pub fn apply_event_to_active_buffer(&mut self, event: &Event) {
49        // Handle View events at Editor level - View events go to SplitViewState, not EditorState
50        // This properly separates Buffer state from View state
51        match event {
52            Event::Scroll { line_offset } => {
53                self.handle_scroll_event(*line_offset);
54                return;
55            }
56            Event::SetViewport { top_line } => {
57                self.handle_set_viewport_event(*top_line);
58                return;
59            }
60            Event::Recenter => {
61                self.handle_recenter_event();
62                return;
63            }
64            _ => {}
65        }
66
67        // Any buffer-modifying event commits the user to this file, so promote
68        // it out of preview mode. Cursor moves and view-only events don't
69        // count — only real edits (Insert / Delete / BulkEdit, or a Batch
70        // containing any of those) flip the bit. Placed here (rather than
71        // in `log_and_apply_event`) because several edit paths bypass
72        // logging and call `apply_event_to_active_buffer` directly — notably
73        // `InsertChar` (single-character typing).
74        if event.modifies_buffer() {
75            self.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.collect_lsp_changes(event);
82
83        // Calculate line info for plugin hooks (using same pre-modification buffer state)
84        let line_info = self.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            let split_id = self.effective_active_split();
96            let active_buf = self.active_buffer();
97            let cursors = &mut self
98                .split_view_states
99                .get_mut(&split_id)
100                .unwrap()
101                .keyed_states
102                .get_mut(&active_buf)
103                .unwrap()
104                .cursors;
105            let state = self.buffers.get_mut(&active_buf).unwrap();
106            state.apply(cursors, event);
107        }
108
109        // 1c. Invalidate layouts for all views of this buffer after content changes
110        // Note: recovery_pending is set automatically by the buffer on edits
111        match event {
112            Event::Insert { .. } | Event::Delete { .. } | Event::BulkEdit { .. } => {
113                self.invalidate_layouts_for_buffer(self.active_buffer());
114                self.schedule_semantic_tokens_full_refresh(self.active_buffer());
115                self.schedule_folding_ranges_refresh(self.active_buffer());
116            }
117            Event::Batch { events, .. } => {
118                let has_edits = events
119                    .iter()
120                    .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
121                if has_edits {
122                    self.invalidate_layouts_for_buffer(self.active_buffer());
123                    self.schedule_semantic_tokens_full_refresh(self.active_buffer());
124                    self.schedule_folding_ranges_refresh(self.active_buffer());
125                }
126            }
127            _ => {}
128        }
129
130        // 2. Adjust cursors in other splits that share the same buffer
131        self.adjust_other_split_cursors_for_event(event);
132
133        // 3. Clear search highlights on edit (Insert/Delete events)
134        // This preserves highlights while navigating but clears them when modifying text
135        // EXCEPT during interactive replace where we want to keep highlights visible
136        let in_interactive_replace = self.interactive_replace_state.is_some();
137
138        // Note: We intentionally do NOT clear search overlays on buffer modification.
139        // Overlays have markers that automatically track position changes through edits,
140        // which allows F3/Shift+F3 to find matches at their updated positions.
141        // The visual highlights may be on text that no longer matches the query,
142        // but that's acceptable - user can see where original matches were.
143        let _ = in_interactive_replace; // silence unused warning
144
145        // 3. Trigger plugin hooks for this event (with pre-calculated line info)
146        self.trigger_plugin_hooks_for_event(event, line_info);
147
148        // 4. Notify LSP of the change using pre-calculated positions
149        // For BulkEdit events (undo/redo of code actions, renames, etc.),
150        // collect_lsp_changes returns empty because there are no incremental byte
151        // positions to convert — BulkEdit restores a tree snapshot.  Send a
152        // full-document replacement so the LSP server stays in sync.
153        if lsp_changes.is_empty() && event.modifies_buffer() {
154            if let Some(full_text) = self.active_state().buffer.to_string() {
155                let full_change = vec![TextDocumentContentChangeEvent {
156                    range: None,
157                    range_length: None,
158                    text: full_text,
159                }];
160                self.send_lsp_changes_for_buffer(self.active_buffer(), full_change);
161            }
162        } else {
163            self.send_lsp_changes_for_buffer(self.active_buffer(), lsp_changes);
164        }
165    }
166
167    /// Apply multiple Insert/Delete events efficiently using bulk edit optimization.
168    ///
169    /// This avoids O(n²) complexity by:
170    /// 1. Converting events to (position, delete_len, insert_text) tuples
171    /// 2. Applying all edits in a single tree pass via apply_bulk_edits
172    /// 3. Creating a BulkEdit event for undo (stores tree snapshot via Arc clone = O(1))
173    ///
174    /// # Arguments
175    /// * `events` - Vec of Insert/Delete events (sorted by position descending for correct application)
176    /// * `description` - Description for the undo log
177    ///
178    /// # Returns
179    /// The BulkEdit event that was applied, for tracking purposes
180    pub fn apply_events_as_bulk_edit(
181        &mut self,
182        events: Vec<Event>,
183        description: String,
184    ) -> Option<Event> {
185        use crate::model::event::CursorId;
186
187        // Check if any events modify the buffer
188        let has_buffer_mods = events
189            .iter()
190            .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
191
192        if !has_buffer_mods {
193            // No buffer modifications - use regular Batch
194            return None;
195        }
196
197        // Multi-cursor edits and code-action rewrites go through this path
198        // (not `apply_event_to_active_buffer`). Promote any preview tab
199        // here too so the invariant "edited buffer is never preview"
200        // holds regardless of which edit path runs.
201        self.promote_active_buffer_from_preview();
202
203        let active_buf = self.active_buffer();
204        let split_id = self.split_manager.active_split();
205
206        // Capture old cursor states from SplitViewState (sole source of truth)
207        let old_cursors: Vec<(CursorId, usize, Option<usize>)> = self
208            .split_view_states
209            .get(&split_id)
210            .unwrap()
211            .keyed_states
212            .get(&active_buf)
213            .unwrap()
214            .cursors
215            .iter()
216            .map(|(id, c)| (id, c.position, c.anchor))
217            .collect();
218
219        let state = self.buffers.get_mut(&active_buf).unwrap();
220
221        // Snapshot buffer state for undo (piece tree + buffers)
222        let old_snapshot = state.buffer.snapshot_buffer_state();
223
224        // Convert events to edit tuples: (position, delete_len, insert_text)
225        // Events must be sorted by position descending (later positions first)
226        // This ensures earlier edits don't shift positions of later edits
227        let mut edits: Vec<(usize, usize, String)> = Vec::new();
228
229        for event in &events {
230            match event {
231                Event::Insert { position, text, .. } => {
232                    edits.push((*position, 0, text.clone()));
233                }
234                Event::Delete { range, .. } => {
235                    edits.push((range.start, range.len(), String::new()));
236                }
237                _ => {}
238            }
239        }
240
241        // Sort edits by position descending (required by apply_bulk_edits)
242        edits.sort_by(|a, b| b.0.cmp(&a.0));
243
244        // Convert to references for apply_bulk_edits
245        let edit_refs: Vec<(usize, usize, &str)> = edits
246            .iter()
247            .map(|(pos, del, text)| (*pos, *del, text.as_str()))
248            .collect();
249
250        // Snapshot displaced markers before edits so undo can restore them exactly.
251        let displaced_markers = state.capture_displaced_markers_bulk(&edits);
252
253        // Apply bulk edits
254        let _delta = state.buffer.apply_bulk_edits(&edit_refs);
255
256        // Convert edit list to lengths-only for marker replay.
257        // Merge edits at the same position into a single (pos, del_len, ins_len)
258        // tuple. This is necessary because delete+insert at the same position
259        // (e.g., line move: delete block, insert rearranged block) should be
260        // treated as a replacement, not two independent adjustments.
261        let edit_lengths: Vec<(usize, usize, usize)> = {
262            let mut lengths: Vec<(usize, usize, usize)> = Vec::new();
263            for (pos, del_len, text) in &edits {
264                if let Some(last) = lengths.last_mut() {
265                    if last.0 == *pos {
266                        // Same position: merge del and ins lengths
267                        last.1 += del_len;
268                        last.2 += text.len();
269                        continue;
270                    }
271                }
272                lengths.push((*pos, *del_len, text.len()));
273            }
274            lengths
275        };
276
277        // Adjust markers and margins using the merged edit lengths.
278        // Using merged edits (net delta for same-position replacements) avoids
279        // the marker-at-boundary problem where sequential delete+insert at the
280        // same position pushes markers incorrectly.
281        for &(pos, del_len, ins_len) in &edit_lengths {
282            if del_len > 0 && ins_len > 0 {
283                // Replacement: adjust by net delta only
284                if ins_len > del_len {
285                    state.marker_list.adjust_for_insert(pos, ins_len - del_len);
286                    state.margins.adjust_for_insert(pos, ins_len - del_len);
287                } else if del_len > ins_len {
288                    state.marker_list.adjust_for_delete(pos, del_len - ins_len);
289                    state.margins.adjust_for_delete(pos, del_len - ins_len);
290                }
291                // Equal: net delta 0, no adjustment needed
292            } else if del_len > 0 {
293                state.marker_list.adjust_for_delete(pos, del_len);
294                state.margins.adjust_for_delete(pos, del_len);
295            } else if ins_len > 0 {
296                state.marker_list.adjust_for_insert(pos, ins_len);
297                state.margins.adjust_for_insert(pos, ins_len);
298            }
299        }
300
301        // Snapshot buffer state after edits (for redo)
302        let new_snapshot = state.buffer.snapshot_buffer_state();
303
304        // Calculate new cursor positions based on events
305        // Process cursor movements from the original events
306        let mut new_cursors: Vec<(CursorId, usize, Option<usize>)> = old_cursors.clone();
307
308        // Calculate position adjustments from edits (sorted ascending by position)
309        // Each entry is (edit_position, delta) where delta = insert_len - delete_len
310        let mut position_deltas: Vec<(usize, isize)> = Vec::new();
311        for (pos, del_len, text) in &edits {
312            let delta = text.len() as isize - *del_len as isize;
313            position_deltas.push((*pos, delta));
314        }
315        position_deltas.sort_by_key(|(pos, _)| *pos);
316
317        // Helper: calculate cumulative shift for a position based on edits at lower positions
318        let calc_shift = |original_pos: usize| -> isize {
319            let mut shift: isize = 0;
320            for (edit_pos, delta) in &position_deltas {
321                if *edit_pos < original_pos {
322                    shift += delta;
323                }
324            }
325            shift
326        };
327
328        // Apply adjustments to cursor positions
329        // First check for explicit MoveCursor events (e.g., from indent operations)
330        // These take precedence over implicit cursor updates from Insert/Delete
331        for (cursor_id, ref mut pos, ref mut anchor) in &mut new_cursors {
332            let mut found_move_cursor = false;
333            // Save original position before any modifications - needed for shift calculation
334            let original_pos = *pos;
335
336            // Check if this cursor has an Insert at its original position (auto-close pattern).
337            // For auto-close, Insert is at cursor position and MoveCursor is relative to original state.
338            // For other operations (like indent), Insert is elsewhere and MoveCursor already accounts for shifts.
339            let insert_at_cursor_pos = events.iter().any(|e| {
340                matches!(e, Event::Insert { position, cursor_id: c, .. }
341                    if *c == *cursor_id && *position == original_pos)
342            });
343
344            // First pass: look for explicit MoveCursor events for this cursor
345            for event in &events {
346                if let Event::MoveCursor {
347                    cursor_id: event_cursor,
348                    new_position,
349                    new_anchor,
350                    ..
351                } = event
352                {
353                    if event_cursor == cursor_id {
354                        // Only adjust for shifts if the Insert was at the cursor's original position
355                        // (like auto-close). For other operations (like indent where Insert is at
356                        // line start), the MoveCursor already accounts for the shift.
357                        let shift = if insert_at_cursor_pos {
358                            calc_shift(original_pos)
359                        } else {
360                            0
361                        };
362                        *pos = (*new_position as isize + shift).max(0) as usize;
363                        *anchor = *new_anchor;
364                        found_move_cursor = true;
365                    }
366                }
367            }
368
369            // If no explicit MoveCursor, derive position from Insert/Delete
370            if !found_move_cursor {
371                let mut found_edit = false;
372                for event in &events {
373                    match event {
374                        Event::Insert {
375                            position,
376                            text,
377                            cursor_id: event_cursor,
378                        } if event_cursor == cursor_id => {
379                            // For insert, cursor moves to end of inserted text
380                            // Account for shifts from edits at lower positions
381                            let shift = calc_shift(*position);
382                            let adjusted_pos = (*position as isize + shift).max(0) as usize;
383                            *pos = adjusted_pos.saturating_add(text.len());
384                            *anchor = None;
385                            found_edit = true;
386                        }
387                        Event::Delete {
388                            range,
389                            cursor_id: event_cursor,
390                            ..
391                        } if event_cursor == cursor_id => {
392                            // For delete, cursor moves to start of deleted range
393                            // Account for shifts from edits at lower positions
394                            let shift = calc_shift(range.start);
395                            *pos = (range.start as isize + shift).max(0) as usize;
396                            *anchor = None;
397                            found_edit = true;
398                        }
399                        _ => {}
400                    }
401                }
402
403                // If this cursor had no events at all (e.g., cursor at end of buffer
404                // during Delete, or at start during Backspace), still adjust its position
405                // for shifts caused by other cursors' edits.
406                if !found_edit {
407                    let shift = calc_shift(original_pos);
408                    *pos = (original_pos as isize + shift).max(0) as usize;
409                }
410            }
411        }
412
413        // Update cursors in SplitViewState (sole source of truth)
414        {
415            let cursors = &mut self
416                .split_view_states
417                .get_mut(&split_id)
418                .unwrap()
419                .keyed_states
420                .get_mut(&active_buf)
421                .unwrap()
422                .cursors;
423            for (cursor_id, position, anchor) in &new_cursors {
424                if let Some(cursor) = cursors.get_mut(*cursor_id) {
425                    cursor.position = *position;
426                    cursor.anchor = *anchor;
427                }
428            }
429        }
430
431        // Invalidate highlighter
432        self.buffers
433            .get_mut(&active_buf)
434            .unwrap()
435            .highlighter
436            .invalidate_all();
437
438        // Create BulkEdit event with both buffer snapshots
439        let bulk_edit = Event::BulkEdit {
440            old_snapshot: Some(old_snapshot),
441            new_snapshot: Some(new_snapshot),
442            old_cursors,
443            new_cursors,
444            description,
445            edits: edit_lengths,
446            displaced_markers,
447        };
448
449        // Post-processing (layout invalidation, split cursor sync, etc.)
450        self.invalidate_layouts_for_buffer(self.active_buffer());
451        self.adjust_other_split_cursors_for_event(&bulk_edit);
452        // Note: Do NOT clear search overlays - markers track through edits for F3/Shift+F3
453
454        // Notify LSP of the change using full document replacement.
455        // Bulk edits combine multiple Delete+Insert operations into a single tree pass,
456        // so computing individual incremental LSP changes is not feasible. Instead,
457        // send the full document content which is always correct.
458        let buffer_id = self.active_buffer();
459        let full_content_change = self
460            .buffers
461            .get(&buffer_id)
462            .and_then(|s| s.buffer.to_string())
463            .map(|text| {
464                vec![TextDocumentContentChangeEvent {
465                    range: None,
466                    range_length: None,
467                    text,
468                }]
469            })
470            .unwrap_or_default();
471        if !full_content_change.is_empty() {
472            self.send_lsp_changes_for_buffer(buffer_id, full_content_change);
473        }
474
475        Some(bulk_edit)
476    }
477
478    /// Trigger plugin hooks for an event (if any)
479    /// line_info contains pre-calculated line numbers from BEFORE buffer modification
480    fn trigger_plugin_hooks_for_event(&mut self, event: &Event, line_info: EventLineInfo) {
481        let buffer_id = self.active_buffer();
482
483        // Convert event to hook args and fire the appropriate hook
484        let mut cursor_changed_lines = false;
485        let hook_args = match event {
486            Event::Insert { position, text, .. } => {
487                let insert_position = *position;
488                let insert_len = text.len();
489
490                // Adjust byte ranges for the insertion
491                if let Some(seen) = self.seen_byte_ranges.get_mut(&buffer_id) {
492                    // Collect adjusted ranges:
493                    // - Ranges ending before insert: keep unchanged
494                    // - Ranges containing insert point: remove (content changed)
495                    // - Ranges starting after insert: shift by insert_len
496                    let adjusted: std::collections::HashSet<(usize, usize)> = seen
497                        .iter()
498                        .filter_map(|&(start, end)| {
499                            if end <= insert_position {
500                                // Range ends before insert - unchanged
501                                Some((start, end))
502                            } else if start >= insert_position {
503                                // Range starts at or after insert - shift forward
504                                Some((start + insert_len, end + insert_len))
505                            } else {
506                                // Range contains insert point - invalidate
507                                None
508                            }
509                        })
510                        .collect();
511                    *seen = adjusted;
512                }
513
514                Some((
515                    "after_insert",
516                    crate::services::plugins::hooks::HookArgs::AfterInsert {
517                        buffer_id,
518                        position: *position,
519                        text: text.clone(),
520                        // Byte range of the affected area
521                        affected_start: insert_position,
522                        affected_end: insert_position + insert_len,
523                        // Line info from pre-modification buffer
524                        start_line: line_info.start_line,
525                        end_line: line_info.end_line,
526                        lines_added: line_info.line_delta.max(0) as usize,
527                    },
528                ))
529            }
530            Event::Delete {
531                range,
532                deleted_text,
533                ..
534            } => {
535                let delete_start = range.start;
536
537                // Adjust byte ranges for the deletion
538                let delete_end = range.end;
539                let delete_len = delete_end - delete_start;
540                if let Some(seen) = self.seen_byte_ranges.get_mut(&buffer_id) {
541                    // Collect adjusted ranges:
542                    // - Ranges ending before delete start: keep unchanged
543                    // - Ranges overlapping deletion: remove (content changed)
544                    // - Ranges starting after delete end: shift backward by delete_len
545                    let adjusted: std::collections::HashSet<(usize, usize)> = seen
546                        .iter()
547                        .filter_map(|&(start, end)| {
548                            if end <= delete_start {
549                                // Range ends before delete - unchanged
550                                Some((start, end))
551                            } else if start >= delete_end {
552                                // Range starts after delete - shift backward
553                                Some((start - delete_len, end - delete_len))
554                            } else {
555                                // Range overlaps deletion - invalidate
556                                None
557                            }
558                        })
559                        .collect();
560                    *seen = adjusted;
561                }
562
563                Some((
564                    "after_delete",
565                    crate::services::plugins::hooks::HookArgs::AfterDelete {
566                        buffer_id,
567                        range: range.clone(),
568                        deleted_text: deleted_text.clone(),
569                        // Byte position and length of deleted content
570                        affected_start: delete_start,
571                        deleted_len: deleted_text.len(),
572                        // Line info from pre-modification buffer
573                        start_line: line_info.start_line,
574                        end_line: line_info.end_line,
575                        lines_removed: (-line_info.line_delta).max(0) as usize,
576                    },
577                ))
578            }
579            Event::Batch { events, .. } => {
580                // Fire hooks for each event in the batch
581                // Note: For batches, line info is approximate since buffer already modified
582                // Individual events will use the passed line_info which covers the whole batch
583                for e in events {
584                    // Use default line info for sub-events - they share the batch's line_info
585                    // This is a simplification; proper tracking would need per-event pre-calculation
586                    let sub_line_info = self.calculate_event_line_info(e);
587                    self.trigger_plugin_hooks_for_event(e, sub_line_info);
588                }
589                None
590            }
591            Event::MoveCursor {
592                cursor_id,
593                old_position,
594                new_position,
595                ..
596            } => {
597                // Get line numbers for old and new positions (1-indexed for plugins)
598                let old_line = self.active_state().buffer.get_line_number(*old_position) + 1;
599                let line = self.active_state().buffer.get_line_number(*new_position) + 1;
600                cursor_changed_lines = old_line != line;
601                let text_props = self
602                    .active_state()
603                    .text_properties
604                    .get_at(*new_position)
605                    .into_iter()
606                    .map(|tp| tp.properties.clone())
607                    .collect();
608                Some((
609                    "cursor_moved",
610                    crate::services::plugins::hooks::HookArgs::CursorMoved {
611                        buffer_id,
612                        cursor_id: *cursor_id,
613                        old_position: *old_position,
614                        new_position: *new_position,
615                        line,
616                        text_properties: text_props,
617                    },
618                ))
619            }
620            _ => None,
621        };
622
623        // Fire the hook to TypeScript plugins
624        if let Some((hook_name, ref args)) = hook_args {
625            // Update the full plugin state snapshot BEFORE firing the hook
626            // This ensures the plugin can read up-to-date state (diff, cursors, viewport, etc.)
627            // Without this, there's a race condition where the async hook might read stale data
628            #[cfg(feature = "plugins")]
629            self.update_plugin_state_snapshot();
630
631            self.plugin_manager.run_hook(hook_name, args.clone());
632        }
633
634        // After inter-line cursor_moved, proactively refresh lines so
635        // cursor-dependent conceals (e.g. emphasis auto-expose in compose
636        // mode tables) update in the same frame. Without this, there's a
637        // one-frame lag: the cursor_moved hook fires async to the plugin
638        // which calls refreshLines() back, but that round-trip means the
639        // first render after the cursor move still shows stale conceals.
640        //
641        // Only refresh on inter-line movement: intra-line moves (e.g.
642        // Left/Right within a row) don't change which row is auto-exposed,
643        // and the plugin's async refreshLines() handles span-level changes.
644        if cursor_changed_lines {
645            self.handle_refresh_lines(buffer_id);
646        }
647    }
648
649    /// Handle scroll events using the SplitViewState's viewport
650    ///
651    /// View events (like Scroll) go to SplitViewState, not EditorState.
652    /// This correctly handles scroll limits when view transforms inject headers.
653    /// Also syncs to EditorState.viewport for the active split (used in rendering).
654    fn handle_scroll_event(&mut self, line_offset: isize) {
655        use crate::view::ui::view_pipeline::ViewLineIterator;
656
657        let active_split = self.split_manager.active_split();
658
659        // Check if this split is in a scroll sync group (anchor-based sync for diffs)
660        // Mark both splits to skip ensure_visible so cursor doesn't override scroll
661        // The sync_scroll_groups() at render time will sync the other split
662        if let Some(group) = self
663            .scroll_sync_manager
664            .find_group_for_split(active_split.into())
665        {
666            let left = group.left_split;
667            let right = group.right_split;
668            if let Some(vs) = self.split_view_states.get_mut(&LeafId(left)) {
669                vs.viewport.set_skip_ensure_visible();
670            }
671            if let Some(vs) = self.split_view_states.get_mut(&LeafId(right)) {
672                vs.viewport.set_skip_ensure_visible();
673            }
674            // Continue to scroll the active split normally below
675        }
676
677        // Fall back to simple sync_group (same delta to all splits)
678        let sync_group = self
679            .split_view_states
680            .get(&active_split)
681            .and_then(|vs| vs.sync_group);
682        let splits_to_scroll = if let Some(group_id) = sync_group {
683            self.split_manager
684                .get_splits_in_group(group_id, &self.split_view_states)
685        } else {
686            vec![active_split]
687        };
688
689        for split_id in splits_to_scroll {
690            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
691                id
692            } else {
693                continue;
694            };
695            let tab_size = self.config.editor.tab_size;
696
697            // Get view_transform tokens from SplitViewState (if any)
698            let view_transform_tokens = self
699                .split_view_states
700                .get(&split_id)
701                .and_then(|vs| vs.view_transform.as_ref())
702                .map(|vt| vt.tokens.clone());
703
704            // Get mutable references to both buffer and view state
705            if let Some(state) = self.buffers.get_mut(&buffer_id) {
706                // Collect plugin soft-break positions BEFORE re-borrowing the
707                // buffer so the viewport's visual-row math matches the renderer
708                // (avoids the wheel-absorbed / empty-bottom mouse-scroll bugs
709                // for compose-mode markdown — see scroll_down_visual).
710                let soft_breaks = state.collect_soft_break_positions();
711                let buffer = &mut state.buffer;
712                if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
713                    if let Some(tokens) = view_transform_tokens {
714                        // Use view-aware scrolling with the transform's tokens
715                        let view_lines: Vec<_> =
716                            ViewLineIterator::new(&tokens, false, false, tab_size, false).collect();
717                        view_state
718                            .viewport
719                            .scroll_view_lines(&view_lines, line_offset);
720                    } else {
721                        // No view transform - use traditional buffer-based scrolling
722                        if line_offset > 0 {
723                            view_state.viewport.scroll_down(
724                                buffer,
725                                &soft_breaks,
726                                line_offset as usize,
727                            );
728                        } else {
729                            view_state.viewport.scroll_up(
730                                buffer,
731                                &soft_breaks,
732                                line_offset.unsigned_abs(),
733                            );
734                        }
735                    }
736                    // Mark to skip ensure_visible on next render so the scroll isn't undone
737                    view_state.viewport.set_skip_ensure_visible();
738                }
739            }
740        }
741    }
742
743    /// Handle SetViewport event using SplitViewState's viewport
744    fn handle_set_viewport_event(&mut self, top_line: usize) {
745        let active_split = self.split_manager.active_split();
746
747        // Check if this split is in a scroll sync group (anchor-based sync for diffs)
748        // If so, set the group's scroll_line and let render sync the viewports
749        if self
750            .scroll_sync_manager
751            .is_split_synced(active_split.into())
752        {
753            if let Some(group) = self
754                .scroll_sync_manager
755                .find_group_for_split_mut(active_split.into())
756            {
757                // Convert line to left buffer space if coming from right split
758                let scroll_line = if group.is_left_split(active_split.into()) {
759                    top_line
760                } else {
761                    group.right_to_left_line(top_line)
762                };
763                group.set_scroll_line(scroll_line);
764            }
765
766            // Mark both splits to skip ensure_visible
767            if let Some(group) = self
768                .scroll_sync_manager
769                .find_group_for_split(active_split.into())
770            {
771                let left = group.left_split;
772                let right = group.right_split;
773                if let Some(vs) = self.split_view_states.get_mut(&LeafId(left)) {
774                    vs.viewport.set_skip_ensure_visible();
775                }
776                if let Some(vs) = self.split_view_states.get_mut(&LeafId(right)) {
777                    vs.viewport.set_skip_ensure_visible();
778                }
779            }
780            return;
781        }
782
783        // Fall back to simple sync_group (same line to all splits)
784        let sync_group = self
785            .split_view_states
786            .get(&active_split)
787            .and_then(|vs| vs.sync_group);
788        let splits_to_scroll = if let Some(group_id) = sync_group {
789            self.split_manager
790                .get_splits_in_group(group_id, &self.split_view_states)
791        } else {
792            vec![active_split]
793        };
794
795        for split_id in splits_to_scroll {
796            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
797                id
798            } else {
799                continue;
800            };
801
802            if let Some(state) = self.buffers.get_mut(&buffer_id) {
803                let buffer = &mut state.buffer;
804                if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
805                    view_state.viewport.scroll_to(buffer, top_line);
806                    // Mark to skip ensure_visible on next render so the scroll isn't undone
807                    view_state.viewport.set_skip_ensure_visible();
808                }
809            }
810        }
811    }
812
813    /// Handle Recenter event using SplitViewState's viewport
814    fn handle_recenter_event(&mut self) {
815        let active_split = self.split_manager.active_split();
816
817        // Find other splits in the same sync group if any
818        let sync_group = self
819            .split_view_states
820            .get(&active_split)
821            .and_then(|vs| vs.sync_group);
822        let splits_to_recenter = if let Some(group_id) = sync_group {
823            self.split_manager
824                .get_splits_in_group(group_id, &self.split_view_states)
825        } else {
826            vec![active_split]
827        };
828
829        for split_id in splits_to_recenter {
830            let buffer_id = if let Some(id) = self.split_manager.buffer_for_split(split_id) {
831                id
832            } else {
833                continue;
834            };
835
836            if let Some(state) = self.buffers.get_mut(&buffer_id) {
837                let buffer = &mut state.buffer;
838                let view_state = self.split_view_states.get_mut(&split_id);
839
840                if let Some(view_state) = view_state {
841                    // Recenter viewport on cursor
842                    let cursor = *view_state.cursors.primary();
843                    let viewport_height = view_state.viewport.visible_line_count();
844                    let target_rows_from_top = viewport_height / 2;
845
846                    // Move backwards from cursor position target_rows_from_top lines
847                    let mut iter = buffer.line_iterator(cursor.position, 80);
848                    for _ in 0..target_rows_from_top {
849                        if iter.prev().is_none() {
850                            break;
851                        }
852                    }
853                    let new_top_byte = iter.current_position();
854                    view_state.viewport.top_byte = new_top_byte;
855                    // Mark to skip ensure_visible on next render so the scroll isn't undone
856                    view_state.viewport.set_skip_ensure_visible();
857                }
858            }
859        }
860    }
861
862    /// Invalidate layouts for all splits viewing a specific buffer
863    ///
864    /// Called after buffer content changes (Insert/Delete) to mark
865    /// layouts as dirty, forcing rebuild on next access.
866    /// Also clears any cached view transform since its token source_offsets
867    /// become stale after buffer edits.
868    pub(super) fn invalidate_layouts_for_buffer(&mut self, buffer_id: BufferId) {
869        // Find all splits that display this buffer
870        let splits_for_buffer = self.split_manager.splits_for_buffer(buffer_id);
871
872        // Invalidate layout and clear stale view transform for each split
873        for split_id in splits_for_buffer {
874            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
875                view_state.invalidate_layout();
876                // Clear cached view transform — its token source_offsets are from
877                // before the edit and would cause conceals to be applied at wrong positions.
878                // The view_transform_request hook will fire on the next render to rebuild it.
879                view_state.view_transform = None;
880                // Mark as stale so that any pending SubmitViewTransform commands
881                // (from a previous view_transform_request) are rejected.
882                view_state.view_transform_stale = true;
883            }
884        }
885    }
886}