Skip to main content

fresh/app/
render.rs

1use super::*;
2use anyhow::Result as AnyhowResult;
3use rust_i18n::t;
4
5impl Editor {
6    /// Render the editor to the terminal
7    pub fn render(&mut self, frame: &mut Frame) {
8        let _span = tracing::trace_span!("render").entered();
9        let size = frame.area();
10
11        // For scroll sync groups, we need to update the active split's viewport position BEFORE
12        // calling sync_scroll_groups, so that the sync reads the correct position.
13        // Otherwise, cursor movements like 'G' (go to end) won't sync properly because
14        // viewport.top_byte hasn't been updated yet.
15        let active_split = self.split_manager.active_split();
16        self.pre_sync_ensure_visible(active_split);
17
18        // Synchronize scroll sync groups (anchor-based scroll for side-by-side diffs)
19        // This sets viewport positions based on the authoritative scroll_line in each group
20        self.sync_scroll_groups();
21
22        // NOTE: Viewport sync with cursor is handled by split_rendering.rs which knows the
23        // correct content area dimensions. Don't sync here with incorrect EditorState viewport size.
24
25        // Prepare all buffers for rendering (pre-load viewport data for lazy loading)
26        // Each split may have a different viewport position on the same buffer
27        let mut semantic_ranges: std::collections::HashMap<BufferId, (usize, usize)> =
28            std::collections::HashMap::new();
29        for (split_id, view_state) in &self.split_view_states {
30            if let Some(buffer_id) = self.split_manager.get_buffer_id(*split_id) {
31                if let Some(state) = self.buffers.get(&buffer_id) {
32                    let start_line = state.buffer.get_line_number(view_state.viewport.top_byte);
33                    let visible_lines = view_state.viewport.visible_line_count().saturating_sub(1);
34                    let end_line = start_line.saturating_add(visible_lines);
35                    semantic_ranges
36                        .entry(buffer_id)
37                        .and_modify(|(min_start, max_end)| {
38                            *min_start = (*min_start).min(start_line);
39                            *max_end = (*max_end).max(end_line);
40                        })
41                        .or_insert((start_line, end_line));
42                }
43            }
44        }
45        for (buffer_id, (start_line, end_line)) in semantic_ranges {
46            self.maybe_request_semantic_tokens_range(buffer_id, start_line, end_line);
47            self.maybe_request_semantic_tokens_full_debounced(buffer_id);
48        }
49
50        for (split_id, view_state) in &self.split_view_states {
51            if let Some(buffer_id) = self.split_manager.get_buffer_id(*split_id) {
52                if let Some(state) = self.buffers.get_mut(&buffer_id) {
53                    let top_byte = view_state.viewport.top_byte;
54                    let height = view_state.viewport.height;
55                    if let Err(e) = state.prepare_for_render(top_byte, height) {
56                        tracing::error!("Failed to prepare buffer for render: {}", e);
57                        // Continue with partial rendering
58                    }
59                }
60            }
61        }
62
63        // Refresh search highlights only during incremental search (when prompt is active)
64        // After search is confirmed, overlays exist for ALL matches and shouldn't be overwritten
65        let is_search_prompt_active = self.prompt.as_ref().is_some_and(|p| {
66            matches!(
67                p.prompt_type,
68                PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch
69            )
70        });
71        if is_search_prompt_active {
72            if let Some(ref search_state) = self.search_state {
73                let query = search_state.query.clone();
74                self.update_search_highlights(&query);
75            }
76        }
77
78        // Determine if we need to show search options bar
79        let show_search_options = self.prompt.as_ref().is_some_and(|p| {
80            matches!(
81                p.prompt_type,
82                PromptType::Search
83                    | PromptType::ReplaceSearch
84                    | PromptType::Replace { .. }
85                    | PromptType::QueryReplaceSearch
86                    | PromptType::QueryReplace { .. }
87            )
88        });
89
90        // Hide status bar when suggestions popup or file browser popup is shown
91        let has_suggestions = self
92            .prompt
93            .as_ref()
94            .is_some_and(|p| !p.suggestions.is_empty());
95        let has_file_browser = self.prompt.as_ref().is_some_and(|p| {
96            matches!(
97                p.prompt_type,
98                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
99            )
100        }) && self.file_open_state.is_some();
101
102        // Build main vertical layout: [menu_bar, main_content, status_bar, search_options, prompt_line]
103        // Status bar is hidden when suggestions popup is shown
104        // Search options bar is shown when in search prompt
105        let constraints = vec![
106            Constraint::Length(if self.menu_bar_visible { 1 } else { 0 }), // Menu bar
107            Constraint::Min(0),                                            // Main content area
108            Constraint::Length(if has_suggestions || has_file_browser {
109                0
110            } else {
111                1
112            }), // Status bar (hidden with popups)
113            Constraint::Length(if show_search_options { 1 } else { 0 }),   // Search options bar
114            Constraint::Length(1), // Prompt line (always reserved)
115        ];
116
117        let main_chunks = Layout::default()
118            .direction(Direction::Vertical)
119            .constraints(constraints)
120            .split(size);
121
122        let menu_bar_area = main_chunks[0];
123        let main_content_area = main_chunks[1];
124        let status_bar_idx = 2;
125        let search_options_idx = 3;
126        let prompt_line_idx = 4;
127
128        // Split main content area based on file explorer visibility
129        // Also keep the layout split if a sync is in progress (to avoid flicker)
130        let editor_content_area;
131        let file_explorer_should_show = self.file_explorer_visible
132            && (self.file_explorer.is_some() || self.file_explorer_sync_in_progress);
133
134        if file_explorer_should_show {
135            // Split horizontally: [file_explorer | editor]
136            tracing::trace!(
137                "render: file explorer layout active (present={}, sync_in_progress={})",
138                self.file_explorer.is_some(),
139                self.file_explorer_sync_in_progress
140            );
141            // Convert f32 percentage (0.0-1.0) to u16 percentage (0-100)
142            let explorer_percent = (self.file_explorer_width_percent * 100.0) as u16;
143            let editor_percent = 100 - explorer_percent;
144            let horizontal_chunks = Layout::default()
145                .direction(Direction::Horizontal)
146                .constraints([
147                    Constraint::Percentage(explorer_percent), // File explorer
148                    Constraint::Percentage(editor_percent),   // Editor area
149                ])
150                .split(main_content_area);
151
152            self.cached_layout.file_explorer_area = Some(horizontal_chunks[0]);
153            editor_content_area = horizontal_chunks[1];
154
155            // Get remote connection info before mutable borrow of file_explorer
156            let remote_connection = self.remote_connection_info().map(|s| s.to_string());
157
158            // Render file explorer (only if we have it - during sync we just keep the area reserved)
159            if let Some(ref mut explorer) = self.file_explorer {
160                let is_focused = self.key_context == KeyContext::FileExplorer;
161
162                // Build set of files with unsaved changes
163                let mut files_with_unsaved_changes = std::collections::HashSet::new();
164                for (buffer_id, state) in &self.buffers {
165                    if state.buffer.is_modified() {
166                        if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
167                            if let Some(file_path) = metadata.file_path() {
168                                files_with_unsaved_changes.insert(file_path.clone());
169                            }
170                        }
171                    }
172                }
173
174                let close_button_hovered = matches!(
175                    &self.mouse_state.hover_target,
176                    Some(HoverTarget::FileExplorerCloseButton)
177                );
178                FileExplorerRenderer::render(
179                    explorer,
180                    frame,
181                    horizontal_chunks[0],
182                    is_focused,
183                    &files_with_unsaved_changes,
184                    &self.file_explorer_decoration_cache,
185                    &self.keybindings,
186                    self.key_context,
187                    &self.theme,
188                    close_button_hovered,
189                    remote_connection.as_deref(),
190                );
191            }
192            // Note: if file_explorer is None but sync_in_progress is true,
193            // we just leave the area blank (or could render a placeholder)
194        } else {
195            // No file explorer: use entire main content area for editor
196            self.cached_layout.file_explorer_area = None;
197            editor_content_area = main_content_area;
198        }
199
200        // Note: Tabs are now rendered within each split by SplitRenderer
201
202        // Trigger lines_changed hooks for newly visible lines in all visible buffers
203        // This allows plugins to add overlays before rendering
204        // Only lines that haven't been seen before are sent (batched for efficiency)
205        // Use non-blocking hooks to avoid deadlock when actions are awaiting
206        if self.plugin_manager.is_active() {
207            let hooks_start = std::time::Instant::now();
208            // Get visible buffers and their areas
209            let visible_buffers = self.split_manager.get_visible_buffers(editor_content_area);
210
211            let mut total_new_lines = 0usize;
212            for (split_id, buffer_id, split_area) in visible_buffers {
213                // Get viewport from SplitViewState (the authoritative source)
214                let viewport_top_byte = self
215                    .split_view_states
216                    .get(&split_id)
217                    .map(|vs| vs.viewport.top_byte)
218                    .unwrap_or(0);
219
220                if let Some(state) = self.buffers.get_mut(&buffer_id) {
221                    // Fire render_start hook once per buffer
222                    self.plugin_manager.run_hook(
223                        "render_start",
224                        crate::services::plugins::hooks::HookArgs::RenderStart { buffer_id },
225                    );
226
227                    // Fire view_transform_request hook with base tokens
228                    // This allows plugins to transform the view (e.g., soft breaks for markdown)
229                    let visible_count = split_area.height as usize;
230                    let is_binary = state.buffer.is_binary();
231                    let line_ending = state.buffer.line_ending();
232                    let base_tokens =
233                        crate::view::ui::split_rendering::SplitRenderer::build_base_tokens_for_hook(
234                            &mut state.buffer,
235                            viewport_top_byte,
236                            self.config.editor.estimated_line_length,
237                            visible_count,
238                            is_binary,
239                            line_ending,
240                        );
241                    let viewport_start = viewport_top_byte;
242                    let viewport_end = base_tokens
243                        .last()
244                        .and_then(|t| t.source_offset)
245                        .unwrap_or(viewport_start);
246                    self.plugin_manager.run_hook(
247                        "view_transform_request",
248                        crate::services::plugins::hooks::HookArgs::ViewTransformRequest {
249                            buffer_id,
250                            split_id,
251                            viewport_start,
252                            viewport_end,
253                            tokens: base_tokens,
254                        },
255                    );
256
257                    // Use the split area height as visible line count
258                    let visible_count = split_area.height as usize;
259                    let top_byte = viewport_top_byte;
260
261                    // Get or create the seen byte ranges set for this buffer
262                    let seen_byte_ranges = self.seen_byte_ranges.entry(buffer_id).or_default();
263
264                    // Collect only NEW lines (not seen before based on byte range)
265                    let mut new_lines: Vec<crate::services::plugins::hooks::LineInfo> = Vec::new();
266                    let mut line_number = state.buffer.get_line_number(top_byte);
267                    let mut iter = state
268                        .buffer
269                        .line_iterator(top_byte, self.config.editor.estimated_line_length);
270
271                    for _ in 0..visible_count {
272                        if let Some((line_start, line_content)) = iter.next_line() {
273                            let byte_end = line_start + line_content.len();
274                            let byte_range = (line_start, byte_end);
275
276                            // Only add if this byte range hasn't been seen before
277                            if !seen_byte_ranges.contains(&byte_range) {
278                                new_lines.push(crate::services::plugins::hooks::LineInfo {
279                                    line_number,
280                                    byte_start: line_start,
281                                    byte_end,
282                                    content: line_content,
283                                });
284                                seen_byte_ranges.insert(byte_range);
285                            }
286                            line_number += 1;
287                        } else {
288                            break;
289                        }
290                    }
291
292                    // Send batched hook if there are new lines
293                    if !new_lines.is_empty() {
294                        total_new_lines += new_lines.len();
295                        self.plugin_manager.run_hook(
296                            "lines_changed",
297                            crate::services::plugins::hooks::HookArgs::LinesChanged {
298                                buffer_id,
299                                lines: new_lines,
300                            },
301                        );
302                    }
303                }
304            }
305            let hooks_elapsed = hooks_start.elapsed();
306            tracing::trace!(
307                new_lines = total_new_lines,
308                elapsed_ms = hooks_elapsed.as_millis(),
309                elapsed_us = hooks_elapsed.as_micros(),
310                "lines_changed hooks total"
311            );
312
313            // Process any plugin commands (like AddOverlay) that resulted from the hooks
314            let commands = self.plugin_manager.process_commands();
315            for command in commands {
316                if let Err(e) = self.handle_plugin_command(command) {
317                    tracing::error!("Error handling plugin command: {}", e);
318                }
319            }
320        }
321
322        // Render editor content (same for both layouts)
323        let lsp_waiting = self.pending_completion_request.is_some()
324            || self.pending_goto_definition_request.is_some();
325
326        // Hide the hardware cursor when menu is open, file explorer is focused, terminal mode,
327        // or settings UI is open
328        // (the file explorer will set its own cursor position when focused)
329        // (terminal mode renders its own cursor via the terminal emulator)
330        // (settings UI is a modal that doesn't need the editor cursor)
331        // This also causes visual cursor indicators in the editor to be dimmed
332        let settings_visible = self.settings_state.as_ref().is_some_and(|s| s.visible);
333        let hide_cursor = self.menu_state.active_menu.is_some()
334            || self.key_context == KeyContext::FileExplorer
335            || self.terminal_mode
336            || settings_visible;
337
338        // Convert HoverTarget to tab hover info for rendering
339        let hovered_tab = match &self.mouse_state.hover_target {
340            Some(HoverTarget::TabName(buffer_id, split_id)) => Some((*buffer_id, *split_id, false)),
341            Some(HoverTarget::TabCloseButton(buffer_id, split_id)) => {
342                Some((*buffer_id, *split_id, true))
343            }
344            _ => None,
345        };
346
347        // Get hovered close split button
348        let hovered_close_split = match &self.mouse_state.hover_target {
349            Some(HoverTarget::CloseSplitButton(split_id)) => Some(*split_id),
350            _ => None,
351        };
352
353        // Get hovered maximize split button
354        let hovered_maximize_split = match &self.mouse_state.hover_target {
355            Some(HoverTarget::MaximizeSplitButton(split_id)) => Some(*split_id),
356            _ => None,
357        };
358
359        let is_maximized = self.split_manager.is_maximized();
360
361        let (split_areas, tab_layouts, close_split_areas, maximize_split_areas, view_line_mappings) =
362            SplitRenderer::render_content(
363                frame,
364                editor_content_area,
365                &self.split_manager,
366                &mut self.buffers,
367                &self.buffer_metadata,
368                &mut self.event_logs,
369                &self.composite_buffers,
370                &mut self.composite_view_states,
371                &self.theme,
372                self.ansi_background.as_ref(),
373                self.background_fade,
374                lsp_waiting,
375                self.config.editor.large_file_threshold_bytes,
376                self.config.editor.line_wrap,
377                self.config.editor.estimated_line_length,
378                self.config.editor.highlight_context_bytes,
379                Some(&mut self.split_view_states),
380                hide_cursor,
381                hovered_tab,
382                hovered_close_split,
383                hovered_maximize_split,
384                is_maximized,
385                self.config.editor.relative_line_numbers,
386                self.tab_bar_visible,
387                self.config.editor.use_terminal_bg,
388            );
389
390        // Detect viewport changes and fire hooks
391        // Compare against previous frame's viewport state (stored in self.previous_viewports)
392        // This correctly detects changes from scroll events that happen before render()
393        if self.plugin_manager.is_active() {
394            for (split_id, view_state) in &self.split_view_states {
395                let current = (
396                    view_state.viewport.top_byte,
397                    view_state.viewport.width,
398                    view_state.viewport.height,
399                );
400                // Compare against previous frame's state
401                // Skip new splits (None case) - only fire hooks for established splits
402                // This matches the original behavior where hooks only fire for splits
403                // that existed at the start of render
404                let (changed, previous) = match self.previous_viewports.get(split_id) {
405                    Some(previous) => (*previous != current, Some(*previous)),
406                    None => (false, None), // Skip new splits until they're established
407                };
408                tracing::trace!(
409                    "viewport_changed check: split={:?} current={:?} previous={:?} changed={}",
410                    split_id,
411                    current,
412                    previous,
413                    changed
414                );
415                if changed {
416                    if let Some(buffer_id) = self.split_manager.get_buffer_id(*split_id) {
417                        tracing::debug!(
418                            "Firing viewport_changed hook: split={:?} buffer={:?} top_byte={}",
419                            split_id,
420                            buffer_id,
421                            view_state.viewport.top_byte
422                        );
423                        self.plugin_manager.run_hook(
424                            "viewport_changed",
425                            crate::services::plugins::hooks::HookArgs::ViewportChanged {
426                                split_id: *split_id,
427                                buffer_id,
428                                top_byte: view_state.viewport.top_byte,
429                                width: view_state.viewport.width,
430                                height: view_state.viewport.height,
431                            },
432                        );
433                    }
434                }
435            }
436        }
437
438        // Update previous_viewports for next frame's comparison
439        self.previous_viewports.clear();
440        for (split_id, view_state) in &self.split_view_states {
441            self.previous_viewports.insert(
442                *split_id,
443                (
444                    view_state.viewport.top_byte,
445                    view_state.viewport.width,
446                    view_state.viewport.height,
447                ),
448            );
449        }
450
451        // Render terminal content on top of split content for terminal buffers
452        self.render_terminal_splits(frame, &split_areas);
453
454        self.cached_layout.split_areas = split_areas;
455        self.cached_layout.tab_layouts = tab_layouts;
456        self.cached_layout.close_split_areas = close_split_areas;
457        self.cached_layout.maximize_split_areas = maximize_split_areas;
458        self.cached_layout.view_line_mappings = view_line_mappings;
459        self.cached_layout.separator_areas = self
460            .split_manager
461            .get_separators_with_ids(editor_content_area);
462        self.cached_layout.editor_content_area = Some(editor_content_area);
463
464        // Render hover highlights for separators and scrollbars
465        self.render_hover_highlights(frame);
466
467        // Render file browser popup for OpenFile prompt, or suggestions for other prompts
468        self.cached_layout.suggestions_area = None;
469        self.file_browser_layout = None;
470        if let Some(prompt) = &self.prompt {
471            // For OpenFile/SwitchProject/SaveFileAs prompt, render the file browser popup
472            if matches!(
473                prompt.prompt_type,
474                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
475            ) {
476                if let Some(file_open_state) = &self.file_open_state {
477                    // Calculate popup area: position above prompt line, covering status bar
478                    let max_height = main_chunks[prompt_line_idx].y.saturating_sub(1).min(20);
479                    let popup_area = ratatui::layout::Rect {
480                        x: 0,
481                        y: main_chunks[prompt_line_idx].y.saturating_sub(max_height),
482                        width: size.width,
483                        height: max_height,
484                    };
485
486                    self.file_browser_layout = crate::view::ui::FileBrowserRenderer::render(
487                        frame,
488                        popup_area,
489                        file_open_state,
490                        &self.theme,
491                        &self.mouse_state.hover_target,
492                        Some(&self.keybindings),
493                    );
494                }
495            } else if !prompt.suggestions.is_empty() {
496                // For other prompts, render suggestions as before
497                // Calculate overlay area: position above prompt line (which is below status bar)
498                let suggestion_count = prompt.suggestions.len().min(10);
499                let is_quick_open =
500                    prompt.prompt_type == crate::view::prompt::PromptType::QuickOpen;
501                let hints_height: u16 = if is_quick_open { 1 } else { 0 };
502                let height = suggestion_count as u16 + 2 + hints_height; // +2 for borders, +1 for hints if QuickOpen
503
504                // Position suggestions above the prompt line (and hints line if present)
505                // The prompt line is at main_chunks[prompt_line_idx], so suggestions go above it
506                let suggestions_area = ratatui::layout::Rect {
507                    x: 0,
508                    y: main_chunks[prompt_line_idx].y.saturating_sub(height),
509                    width: size.width,
510                    height: height - hints_height,
511                };
512
513                // Clear the area behind the suggestions to obscure underlying text
514                frame.render_widget(ratatui::widgets::Clear, suggestions_area);
515
516                self.cached_layout.suggestions_area = SuggestionsRenderer::render_with_hover(
517                    frame,
518                    suggestions_area,
519                    prompt,
520                    &self.theme,
521                    self.mouse_state.hover_target.as_ref(),
522                );
523
524                // Render hints line for QuickOpen between suggestions and prompt
525                if is_quick_open {
526                    let hints_area = ratatui::layout::Rect {
527                        x: 0,
528                        y: main_chunks[prompt_line_idx].y.saturating_sub(hints_height),
529                        width: size.width,
530                        height: hints_height,
531                    };
532                    frame.render_widget(ratatui::widgets::Clear, hints_area);
533                    Self::render_quick_open_hints(frame, hints_area, &self.theme);
534                }
535            }
536        }
537
538        // Clone all immutable values before the mutable borrow
539        let display_name = self
540            .buffer_metadata
541            .get(&self.active_buffer())
542            .map(|m| m.display_name.clone())
543            .unwrap_or_else(|| "[No Name]".to_string());
544        let status_message = self.status_message.clone();
545        let plugin_status_message = self.plugin_status_message.clone();
546        let prompt = self.prompt.clone();
547        let lsp_status = self.lsp_status.clone();
548        let theme = self.theme.clone();
549        let keybindings_cloned = self.keybindings.clone(); // Clone the keybindings
550        let chord_state_cloned = self.chord_state.clone(); // Clone the chord state
551
552        // Get update availability info
553        let update_available = self.latest_version().map(|v| v.to_string());
554
555        // Render status bar (hidden when suggestions or file browser popup is shown)
556        if !has_suggestions && !has_file_browser {
557            // Get warning level for colored indicator (respects config setting)
558            let (warning_level, general_warning_count) =
559                if self.config.warnings.show_status_indicator {
560                    (
561                        self.get_effective_warning_level(),
562                        self.get_general_warning_count(),
563                    )
564                } else {
565                    (WarningLevel::None, 0)
566                };
567
568            // Compute status bar hover state for styling
569            use crate::view::ui::status_bar::StatusBarHover;
570            let status_bar_hover = match &self.mouse_state.hover_target {
571                Some(HoverTarget::StatusBarLspIndicator) => StatusBarHover::LspIndicator,
572                Some(HoverTarget::StatusBarWarningBadge) => StatusBarHover::WarningBadge,
573                Some(HoverTarget::StatusBarLineEndingIndicator) => {
574                    StatusBarHover::LineEndingIndicator
575                }
576                Some(HoverTarget::StatusBarLanguageIndicator) => StatusBarHover::LanguageIndicator,
577                _ => StatusBarHover::None,
578            };
579
580            // Get remote connection info if editing remote files
581            let remote_connection = self.remote_connection_info().map(|s| s.to_string());
582
583            let status_bar_layout = StatusBarRenderer::render_status_bar(
584                frame,
585                main_chunks[status_bar_idx],
586                self.active_state_mut(), // Use the mutable reference
587                &status_message,
588                &plugin_status_message,
589                &lsp_status,
590                &theme,
591                &display_name,
592                &keybindings_cloned,          // Pass the cloned keybindings
593                &chord_state_cloned,          // Pass the cloned chord state
594                update_available.as_deref(),  // Pass update availability
595                warning_level,                // Pass warning level for colored indicator
596                general_warning_count,        // Pass general warning count for badge
597                status_bar_hover,             // Pass hover state for indicator styling
598                remote_connection.as_deref(), // Pass remote connection info
599            );
600
601            // Store status bar layout for click detection
602            let status_bar_area = main_chunks[status_bar_idx];
603            self.cached_layout.status_bar_area =
604                Some((status_bar_area.y, status_bar_area.x, status_bar_area.width));
605            self.cached_layout.status_bar_lsp_area = status_bar_layout.lsp_indicator;
606            self.cached_layout.status_bar_warning_area = status_bar_layout.warning_badge;
607            self.cached_layout.status_bar_line_ending_area =
608                status_bar_layout.line_ending_indicator;
609            self.cached_layout.status_bar_language_area = status_bar_layout.language_indicator;
610            self.cached_layout.status_bar_message_area = status_bar_layout.message_area;
611        }
612
613        // Render search options bar when in search prompt
614        if show_search_options {
615            // Show "Confirm" option only in replace modes
616            let confirm_each = self.prompt.as_ref().and_then(|p| {
617                if matches!(
618                    p.prompt_type,
619                    PromptType::ReplaceSearch
620                        | PromptType::Replace { .. }
621                        | PromptType::QueryReplaceSearch
622                        | PromptType::QueryReplace { .. }
623                ) {
624                    Some(self.search_confirm_each)
625                } else {
626                    None
627                }
628            });
629
630            // Determine hover state for search options
631            use crate::view::ui::status_bar::SearchOptionsHover;
632            let search_options_hover = match &self.mouse_state.hover_target {
633                Some(HoverTarget::SearchOptionCaseSensitive) => SearchOptionsHover::CaseSensitive,
634                Some(HoverTarget::SearchOptionWholeWord) => SearchOptionsHover::WholeWord,
635                Some(HoverTarget::SearchOptionRegex) => SearchOptionsHover::Regex,
636                Some(HoverTarget::SearchOptionConfirmEach) => SearchOptionsHover::ConfirmEach,
637                _ => SearchOptionsHover::None,
638            };
639
640            let search_options_layout = StatusBarRenderer::render_search_options(
641                frame,
642                main_chunks[search_options_idx],
643                self.search_case_sensitive,
644                self.search_whole_word,
645                self.search_use_regex,
646                confirm_each,
647                &theme,
648                &keybindings_cloned,
649                search_options_hover,
650            );
651            self.cached_layout.search_options_layout = Some(search_options_layout);
652        } else {
653            self.cached_layout.search_options_layout = None;
654        }
655
656        // Render prompt line if active
657        if let Some(prompt) = &prompt {
658            // Use specialized renderer for file/folder open prompt to show colorized path
659            if matches!(
660                prompt.prompt_type,
661                crate::view::prompt::PromptType::OpenFile
662                    | crate::view::prompt::PromptType::SwitchProject
663            ) {
664                if let Some(file_open_state) = &self.file_open_state {
665                    StatusBarRenderer::render_file_open_prompt(
666                        frame,
667                        main_chunks[prompt_line_idx],
668                        prompt,
669                        file_open_state,
670                        &theme,
671                    );
672                } else {
673                    StatusBarRenderer::render_prompt(
674                        frame,
675                        main_chunks[prompt_line_idx],
676                        prompt,
677                        &theme,
678                    );
679                }
680            } else {
681                StatusBarRenderer::render_prompt(
682                    frame,
683                    main_chunks[prompt_line_idx],
684                    prompt,
685                    &theme,
686                );
687            }
688        }
689
690        // Render popups from the active buffer state
691        // Clone theme to avoid borrow checker issues with active_state_mut()
692        let theme_clone = self.theme.clone();
693        let hover_target = self.mouse_state.hover_target.clone();
694
695        // Clear popup areas and recalculate
696        self.cached_layout.popup_areas.clear();
697
698        // Collect popup information without holding a mutable borrow
699        let popup_info: Vec<_> = {
700            // Get viewport from active split's SplitViewState
701            let active_split = self.split_manager.active_split();
702            let viewport = self
703                .split_view_states
704                .get(&active_split)
705                .map(|vs| vs.viewport.clone());
706
707            let state = self.active_state_mut();
708            if state.popups.is_visible() {
709                // Get the primary cursor position for popup positioning
710                let primary_cursor = state.cursors.primary();
711                let cursor_screen_pos = viewport
712                    .as_ref()
713                    .map(|vp| vp.cursor_screen_position(&mut state.buffer, primary_cursor))
714                    .unwrap_or((0, 0));
715
716                // Adjust cursor position to account for tab bar (1 line offset)
717                let cursor_screen_pos = (cursor_screen_pos.0, cursor_screen_pos.1 + 1);
718
719                // Collect popup data
720                state
721                    .popups
722                    .all()
723                    .iter()
724                    .enumerate()
725                    .map(|(popup_idx, popup)| {
726                        let popup_area = popup.calculate_area(size, Some(cursor_screen_pos));
727
728                        // Track popup area for mouse hit testing
729                        // Account for description height when calculating the list item area
730                        let desc_height = popup.description_height();
731                        let inner_area = if popup.bordered {
732                            ratatui::layout::Rect {
733                                x: popup_area.x + 1,
734                                y: popup_area.y + 1 + desc_height,
735                                width: popup_area.width.saturating_sub(2),
736                                height: popup_area.height.saturating_sub(2 + desc_height),
737                            }
738                        } else {
739                            ratatui::layout::Rect {
740                                x: popup_area.x,
741                                y: popup_area.y + desc_height,
742                                width: popup_area.width,
743                                height: popup_area.height.saturating_sub(desc_height),
744                            }
745                        };
746
747                        let num_items = match &popup.content {
748                            crate::view::popup::PopupContent::List { items, .. } => items.len(),
749                            _ => 0,
750                        };
751
752                        // Calculate total content lines and scrollbar rect
753                        let total_lines = popup.item_count();
754                        let visible_lines = inner_area.height as usize;
755                        let scrollbar_rect = if total_lines > visible_lines && inner_area.width > 2
756                        {
757                            Some(ratatui::layout::Rect {
758                                x: inner_area.x + inner_area.width - 1,
759                                y: inner_area.y,
760                                width: 1,
761                                height: inner_area.height,
762                            })
763                        } else {
764                            None
765                        };
766
767                        (
768                            popup_idx,
769                            popup_area,
770                            inner_area,
771                            popup.scroll_offset,
772                            num_items,
773                            scrollbar_rect,
774                            total_lines,
775                        )
776                    })
777                    .collect()
778            } else {
779                Vec::new()
780            }
781        };
782
783        // Store popup areas for mouse hit testing
784        self.cached_layout.popup_areas = popup_info.clone();
785
786        // Now render popups
787        let state = self.active_state_mut();
788        if state.popups.is_visible() {
789            for (popup_idx, popup) in state.popups.all().iter().enumerate() {
790                if let Some((_, popup_area, _, _, _, _, _)) = popup_info.get(popup_idx) {
791                    popup.render_with_hover(
792                        frame,
793                        *popup_area,
794                        &theme_clone,
795                        hover_target.as_ref(),
796                    );
797                }
798            }
799        }
800
801        // Render menu bar last so dropdown appears on top of all other content
802        // Update menu context with current editor state
803        self.update_menu_context();
804
805        // Render settings modal (before menu bar so menus can overlay)
806        // Check visibility first to avoid borrow conflict with dimming
807        let settings_visible = self
808            .settings_state
809            .as_ref()
810            .map(|s| s.visible)
811            .unwrap_or(false);
812        if settings_visible {
813            // Dim the editor content behind the settings modal
814            crate::view::dimming::apply_dimming(frame, size);
815        }
816        if let Some(ref mut settings_state) = self.settings_state {
817            if settings_state.visible {
818                settings_state.update_focus_states();
819                let settings_layout = crate::view::settings::render_settings(
820                    frame,
821                    size,
822                    settings_state,
823                    &self.theme,
824                );
825                self.cached_layout.settings_layout = Some(settings_layout);
826            }
827        }
828
829        // Render calibration wizard if active
830        if let Some(ref wizard) = self.calibration_wizard {
831            // Dim the editor content behind the wizard modal
832            crate::view::dimming::apply_dimming(frame, size);
833            crate::view::calibration_wizard::render_calibration_wizard(
834                frame,
835                size,
836                wizard,
837                &self.theme,
838            );
839        }
840
841        // Render event debug dialog if active
842        if let Some(ref debug) = self.event_debug {
843            // Dim the editor content behind the dialog modal
844            crate::view::dimming::apply_dimming(frame, size);
845            crate::view::event_debug::render_event_debug(frame, size, debug, &self.theme);
846        }
847
848        if self.menu_bar_visible {
849            self.cached_layout.menu_layout = Some(crate::view::ui::MenuRenderer::render(
850                frame,
851                menu_bar_area,
852                &self.menus,
853                &self.menu_state,
854                &self.keybindings,
855                &self.theme,
856                self.mouse_state.hover_target.as_ref(),
857            ));
858        } else {
859            self.cached_layout.menu_layout = None;
860        }
861
862        // Render tab context menu if open
863        if let Some(ref menu) = self.tab_context_menu {
864            self.render_tab_context_menu(frame, menu);
865        }
866
867        // Render tab drag drop zone overlay if dragging a tab
868        if let Some(ref drag_state) = self.mouse_state.dragging_tab {
869            if drag_state.is_dragging() {
870                self.render_tab_drop_zone(frame, drag_state);
871            }
872        }
873
874        // Render software mouse cursor when GPM is active
875        // GPM can't draw its cursor on the alternate screen buffer used by TUI apps,
876        // so we draw our own cursor at the tracked mouse position.
877        // This must happen LAST in the render flow so we can read the already-rendered
878        // cell content and invert it.
879        if self.gpm_active {
880            if let Some((col, row)) = self.mouse_cursor_position {
881                use ratatui::style::Modifier;
882
883                // Only render if within screen bounds
884                if col < size.width && row < size.height {
885                    // Get the cell at this position and add REVERSED modifier to invert colors
886                    let buf = frame.buffer_mut();
887                    if let Some(cell) = buf.cell_mut((col, row)) {
888                        cell.set_style(cell.style().add_modifier(Modifier::REVERSED));
889                    }
890                }
891            }
892        }
893
894        // When keyboard capture mode is active, dim all UI elements outside the terminal
895        // to visually indicate that focus is exclusively on the terminal
896        if self.keyboard_capture && self.terminal_mode {
897            // Find the active split's content area
898            let active_split = self.split_manager.active_split();
899            let active_split_area = self
900                .cached_layout
901                .split_areas
902                .iter()
903                .find(|(split_id, _, _, _, _, _)| *split_id == active_split)
904                .map(|(_, _, content_rect, _, _, _)| *content_rect);
905
906            if let Some(terminal_area) = active_split_area {
907                self.apply_keyboard_capture_dimming(frame, terminal_area);
908            }
909        }
910
911        // Convert all colors for terminal capability (256/16 color fallback)
912        crate::view::color_support::convert_buffer_colors(
913            frame.buffer_mut(),
914            self.color_capability,
915        );
916    }
917
918    /// Render the Quick Open hints line showing available mode prefixes
919    fn render_quick_open_hints(
920        frame: &mut Frame,
921        area: ratatui::layout::Rect,
922        theme: &crate::view::theme::Theme,
923    ) {
924        use ratatui::style::{Modifier, Style};
925        use ratatui::text::{Line, Span};
926        use ratatui::widgets::Paragraph;
927        use rust_i18n::t;
928
929        let hints_style = Style::default()
930            .fg(theme.line_number_fg)
931            .bg(theme.suggestion_selected_bg)
932            .add_modifier(Modifier::DIM);
933        let hints_text = t!("quick_open.mode_hints");
934        // Left-align with small margin
935        let left_margin = 2;
936        let hints_width = crate::primitives::display_width::str_width(&hints_text);
937        let mut spans = Vec::new();
938        spans.push(Span::styled(" ".repeat(left_margin), hints_style));
939        spans.push(Span::styled(hints_text.to_string(), hints_style));
940        let remaining = (area.width as usize).saturating_sub(left_margin + hints_width);
941        spans.push(Span::styled(" ".repeat(remaining), hints_style));
942
943        let paragraph = Paragraph::new(Line::from(spans));
944        frame.render_widget(paragraph, area);
945    }
946
947    /// Apply dimming effect to UI elements outside the focused terminal area
948    /// This visually indicates that keyboard capture mode is active
949    fn apply_keyboard_capture_dimming(
950        &self,
951        frame: &mut Frame,
952        terminal_area: ratatui::layout::Rect,
953    ) {
954        let size = frame.area();
955        crate::view::dimming::apply_dimming_excluding(frame, size, Some(terminal_area));
956    }
957
958    /// Render hover highlights for interactive elements (separators, scrollbars)
959    pub(super) fn render_hover_highlights(&self, frame: &mut Frame) {
960        use ratatui::style::Style;
961        use ratatui::text::Span;
962        use ratatui::widgets::Paragraph;
963
964        match &self.mouse_state.hover_target {
965            Some(HoverTarget::SplitSeparator(split_id, direction)) => {
966                // Highlight the separator with hover color
967                for (sid, dir, x, y, length) in &self.cached_layout.separator_areas {
968                    if sid == split_id && dir == direction {
969                        let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
970                        match dir {
971                            SplitDirection::Horizontal => {
972                                let line_text = "─".repeat(*length as usize);
973                                let paragraph =
974                                    Paragraph::new(Span::styled(line_text, hover_style));
975                                frame.render_widget(
976                                    paragraph,
977                                    ratatui::layout::Rect::new(*x, *y, *length, 1),
978                                );
979                            }
980                            SplitDirection::Vertical => {
981                                for offset in 0..*length {
982                                    let paragraph = Paragraph::new(Span::styled("│", hover_style));
983                                    frame.render_widget(
984                                        paragraph,
985                                        ratatui::layout::Rect::new(*x, y + offset, 1, 1),
986                                    );
987                                }
988                            }
989                        }
990                    }
991                }
992            }
993            Some(HoverTarget::ScrollbarThumb(split_id)) => {
994                // Highlight scrollbar thumb
995                for (sid, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
996                    &self.cached_layout.split_areas
997                {
998                    if sid == split_id {
999                        let hover_style = Style::default().bg(self.theme.scrollbar_thumb_hover_fg);
1000                        for row_offset in *thumb_start..*thumb_end {
1001                            let paragraph = Paragraph::new(Span::styled(" ", hover_style));
1002                            frame.render_widget(
1003                                paragraph,
1004                                ratatui::layout::Rect::new(
1005                                    scrollbar_rect.x,
1006                                    scrollbar_rect.y + row_offset as u16,
1007                                    1,
1008                                    1,
1009                                ),
1010                            );
1011                        }
1012                    }
1013                }
1014            }
1015            Some(HoverTarget::ScrollbarTrack(split_id)) => {
1016                // Highlight scrollbar track but preserve the thumb
1017                for (sid, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
1018                    &self.cached_layout.split_areas
1019                {
1020                    if sid == split_id {
1021                        let track_hover_style =
1022                            Style::default().bg(self.theme.scrollbar_track_hover_fg);
1023                        let thumb_style = Style::default().bg(self.theme.scrollbar_thumb_fg);
1024                        for row_offset in 0..scrollbar_rect.height {
1025                            let is_thumb = (row_offset as usize) >= *thumb_start
1026                                && (row_offset as usize) < *thumb_end;
1027                            let style = if is_thumb {
1028                                thumb_style
1029                            } else {
1030                                track_hover_style
1031                            };
1032                            let paragraph = Paragraph::new(Span::styled(" ", style));
1033                            frame.render_widget(
1034                                paragraph,
1035                                ratatui::layout::Rect::new(
1036                                    scrollbar_rect.x,
1037                                    scrollbar_rect.y + row_offset,
1038                                    1,
1039                                    1,
1040                                ),
1041                            );
1042                        }
1043                    }
1044                }
1045            }
1046            Some(HoverTarget::FileExplorerBorder) => {
1047                // Highlight the file explorer border for resize
1048                if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1049                    let hover_style = Style::default().fg(self.theme.split_separator_hover_fg);
1050                    let border_x = explorer_area.x + explorer_area.width;
1051                    for row_offset in 0..explorer_area.height {
1052                        let paragraph = Paragraph::new(Span::styled("│", hover_style));
1053                        frame.render_widget(
1054                            paragraph,
1055                            ratatui::layout::Rect::new(
1056                                border_x,
1057                                explorer_area.y + row_offset,
1058                                1,
1059                                1,
1060                            ),
1061                        );
1062                    }
1063                }
1064            }
1065            // Menu hover is handled by MenuRenderer
1066            _ => {}
1067        }
1068    }
1069
1070    /// Render the tab context menu
1071    fn render_tab_context_menu(&self, frame: &mut Frame, menu: &TabContextMenu) {
1072        use ratatui::style::Style;
1073        use ratatui::text::{Line, Span};
1074        use ratatui::widgets::{Block, Borders, Clear, Paragraph};
1075
1076        let items = super::types::TabContextMenuItem::all();
1077        let menu_width = 22u16; // "Close to the Right" + padding
1078        let menu_height = items.len() as u16 + 2; // items + borders
1079
1080        // Adjust position to stay within screen bounds
1081        let screen_width = frame.area().width;
1082        let screen_height = frame.area().height;
1083
1084        let menu_x = if menu.position.0 + menu_width > screen_width {
1085            screen_width.saturating_sub(menu_width)
1086        } else {
1087            menu.position.0
1088        };
1089
1090        let menu_y = if menu.position.1 + menu_height > screen_height {
1091            screen_height.saturating_sub(menu_height)
1092        } else {
1093            menu.position.1
1094        };
1095
1096        let area = ratatui::layout::Rect::new(menu_x, menu_y, menu_width, menu_height);
1097
1098        // Clear the area first
1099        frame.render_widget(Clear, area);
1100
1101        // Build the menu lines
1102        let mut lines = Vec::new();
1103        for (idx, item) in items.iter().enumerate() {
1104            let is_highlighted = idx == menu.highlighted;
1105
1106            let style = if is_highlighted {
1107                Style::default()
1108                    .fg(self.theme.menu_highlight_fg)
1109                    .bg(self.theme.menu_highlight_bg)
1110            } else {
1111                Style::default()
1112                    .fg(self.theme.menu_dropdown_fg)
1113                    .bg(self.theme.menu_dropdown_bg)
1114            };
1115
1116            // Pad the label to fill the menu width
1117            let label = item.label();
1118            let content_width = (menu_width as usize).saturating_sub(2); // -2 for borders
1119            let padded_label = format!(" {:<width$}", label, width = content_width - 1);
1120
1121            lines.push(Line::from(vec![Span::styled(padded_label, style)]));
1122        }
1123
1124        let block = Block::default()
1125            .borders(Borders::ALL)
1126            .border_style(Style::default().fg(self.theme.menu_border_fg))
1127            .style(Style::default().bg(self.theme.menu_dropdown_bg));
1128
1129        let paragraph = Paragraph::new(lines).block(block);
1130        frame.render_widget(paragraph, area);
1131    }
1132
1133    /// Render the tab drag drop zone overlay
1134    fn render_tab_drop_zone(&self, frame: &mut Frame, drag_state: &super::types::TabDragState) {
1135        use ratatui::style::Modifier;
1136
1137        let Some(ref drop_zone) = drag_state.drop_zone else {
1138            return;
1139        };
1140
1141        let split_id = drop_zone.split_id();
1142
1143        // Find the content area for the target split
1144        let split_area = self
1145            .cached_layout
1146            .split_areas
1147            .iter()
1148            .find(|(sid, _, _, _, _, _)| *sid == split_id)
1149            .map(|(_, _, content_rect, _, _, _)| *content_rect);
1150
1151        let Some(content_rect) = split_area else {
1152            return;
1153        };
1154
1155        // Determine the highlight area based on drop zone type
1156        use super::types::TabDropZone;
1157
1158        let highlight_area = match drop_zone {
1159            TabDropZone::TabBar(_, _) | TabDropZone::SplitCenter(_) => {
1160                // For tab bar and center drops, highlight the entire split area
1161                // This indicates the tab will be added to this split's tab bar
1162                content_rect
1163            }
1164            TabDropZone::SplitLeft(_) => {
1165                // Left 50% of the split (matches the actual split size created)
1166                let width = (content_rect.width / 2).max(3);
1167                ratatui::layout::Rect::new(
1168                    content_rect.x,
1169                    content_rect.y,
1170                    width,
1171                    content_rect.height,
1172                )
1173            }
1174            TabDropZone::SplitRight(_) => {
1175                // Right 50% of the split (matches the actual split size created)
1176                let width = (content_rect.width / 2).max(3);
1177                let x = content_rect.x + content_rect.width - width;
1178                ratatui::layout::Rect::new(x, content_rect.y, width, content_rect.height)
1179            }
1180            TabDropZone::SplitTop(_) => {
1181                // Top 50% of the split (matches the actual split size created)
1182                let height = (content_rect.height / 2).max(2);
1183                ratatui::layout::Rect::new(
1184                    content_rect.x,
1185                    content_rect.y,
1186                    content_rect.width,
1187                    height,
1188                )
1189            }
1190            TabDropZone::SplitBottom(_) => {
1191                // Bottom 50% of the split (matches the actual split size created)
1192                let height = (content_rect.height / 2).max(2);
1193                let y = content_rect.y + content_rect.height - height;
1194                ratatui::layout::Rect::new(content_rect.x, y, content_rect.width, height)
1195            }
1196        };
1197
1198        // Draw the overlay with the drop zone color
1199        // We apply a semi-transparent effect by modifying existing cells
1200        let buf = frame.buffer_mut();
1201        let drop_zone_bg = self.theme.tab_drop_zone_bg;
1202        let drop_zone_border = self.theme.tab_drop_zone_border;
1203
1204        // Fill the highlight area with a semi-transparent overlay
1205        for y in highlight_area.y..highlight_area.y + highlight_area.height {
1206            for x in highlight_area.x..highlight_area.x + highlight_area.width {
1207                if let Some(cell) = buf.cell_mut((x, y)) {
1208                    // Blend the drop zone color with the existing background
1209                    // For a simple effect, we just set the background
1210                    cell.set_bg(drop_zone_bg);
1211
1212                    // Draw border on edges
1213                    let is_border = x == highlight_area.x
1214                        || x == highlight_area.x + highlight_area.width - 1
1215                        || y == highlight_area.y
1216                        || y == highlight_area.y + highlight_area.height - 1;
1217
1218                    if is_border {
1219                        cell.set_fg(drop_zone_border);
1220                        cell.set_style(cell.style().add_modifier(Modifier::BOLD));
1221                    }
1222                }
1223            }
1224        }
1225
1226        // Draw a border indicator based on the zone type
1227        match drop_zone {
1228            TabDropZone::SplitLeft(_) => {
1229                // Draw vertical indicator on left edge
1230                for y in highlight_area.y..highlight_area.y + highlight_area.height {
1231                    if let Some(cell) = buf.cell_mut((highlight_area.x, y)) {
1232                        cell.set_symbol("▌");
1233                        cell.set_fg(drop_zone_border);
1234                    }
1235                }
1236            }
1237            TabDropZone::SplitRight(_) => {
1238                // Draw vertical indicator on right edge
1239                let x = highlight_area.x + highlight_area.width - 1;
1240                for y in highlight_area.y..highlight_area.y + highlight_area.height {
1241                    if let Some(cell) = buf.cell_mut((x, y)) {
1242                        cell.set_symbol("▐");
1243                        cell.set_fg(drop_zone_border);
1244                    }
1245                }
1246            }
1247            TabDropZone::SplitTop(_) => {
1248                // Draw horizontal indicator on top edge
1249                for x in highlight_area.x..highlight_area.x + highlight_area.width {
1250                    if let Some(cell) = buf.cell_mut((x, highlight_area.y)) {
1251                        cell.set_symbol("▀");
1252                        cell.set_fg(drop_zone_border);
1253                    }
1254                }
1255            }
1256            TabDropZone::SplitBottom(_) => {
1257                // Draw horizontal indicator on bottom edge
1258                let y = highlight_area.y + highlight_area.height - 1;
1259                for x in highlight_area.x..highlight_area.x + highlight_area.width {
1260                    if let Some(cell) = buf.cell_mut((x, y)) {
1261                        cell.set_symbol("▄");
1262                        cell.set_fg(drop_zone_border);
1263                    }
1264                }
1265            }
1266            TabDropZone::SplitCenter(_) | TabDropZone::TabBar(_, _) => {
1267                // For center and tab bar, the filled background is sufficient
1268            }
1269        }
1270    }
1271
1272    // === Overlay Management (Event-Driven) ===
1273
1274    /// Add an overlay for decorations (underlines, highlights, etc.)
1275    pub fn add_overlay(
1276        &mut self,
1277        namespace: Option<crate::view::overlay::OverlayNamespace>,
1278        range: Range<usize>,
1279        face: crate::model::event::OverlayFace,
1280        priority: i32,
1281        message: Option<String>,
1282    ) -> crate::view::overlay::OverlayHandle {
1283        let event = Event::AddOverlay {
1284            namespace,
1285            range,
1286            face,
1287            priority,
1288            message,
1289            extend_to_line_end: false,
1290        };
1291        self.apply_event_to_active_buffer(&event);
1292        // Return the handle of the last added overlay
1293        let state = self.active_state();
1294        state
1295            .overlays
1296            .all()
1297            .last()
1298            .map(|o| o.handle.clone())
1299            .unwrap_or_default()
1300    }
1301
1302    /// Remove an overlay by handle
1303    pub fn remove_overlay(&mut self, handle: crate::view::overlay::OverlayHandle) {
1304        let event = Event::RemoveOverlay { handle };
1305        self.apply_event_to_active_buffer(&event);
1306    }
1307
1308    /// Remove all overlays in a range
1309    pub fn remove_overlays_in_range(&mut self, range: Range<usize>) {
1310        let event = Event::RemoveOverlaysInRange { range };
1311        self.active_event_log_mut().append(event.clone());
1312        self.apply_event_to_active_buffer(&event);
1313    }
1314
1315    /// Clear all overlays
1316    pub fn clear_overlays(&mut self) {
1317        let event = Event::ClearOverlays;
1318        self.active_event_log_mut().append(event.clone());
1319        self.apply_event_to_active_buffer(&event);
1320    }
1321
1322    // === Popup Management (Event-Driven) ===
1323
1324    /// Show a popup window
1325    pub fn show_popup(&mut self, popup: crate::model::event::PopupData) {
1326        let event = Event::ShowPopup { popup };
1327        self.active_event_log_mut().append(event.clone());
1328        self.apply_event_to_active_buffer(&event);
1329    }
1330
1331    /// Hide the topmost popup
1332    pub fn hide_popup(&mut self) {
1333        let event = Event::HidePopup;
1334        self.active_event_log_mut().append(event.clone());
1335        self.apply_event_to_active_buffer(&event);
1336
1337        // Clear hover symbol highlight if present
1338        if let Some(handle) = self.hover_symbol_overlay.take() {
1339            let remove_overlay_event = crate::model::event::Event::RemoveOverlay { handle };
1340            self.apply_event_to_active_buffer(&remove_overlay_event);
1341        }
1342        self.hover_symbol_range = None;
1343    }
1344
1345    /// Dismiss transient popups if present
1346    /// These popups should be dismissed on scroll or other user actions
1347    pub(super) fn dismiss_transient_popups(&mut self) {
1348        let is_transient_popup = self
1349            .active_state()
1350            .popups
1351            .top()
1352            .is_some_and(|p| p.transient);
1353
1354        if is_transient_popup {
1355            self.hide_popup();
1356            tracing::trace!("Dismissed transient popup");
1357        }
1358    }
1359
1360    /// Scroll any popup content by delta lines
1361    /// Positive delta scrolls down, negative scrolls up
1362    pub(super) fn scroll_popup(&mut self, delta: i32) {
1363        if let Some(popup) = self.active_state_mut().popups.top_mut() {
1364            popup.scroll_by(delta);
1365            tracing::debug!(
1366                "Scrolled popup by {}, new offset: {}",
1367                delta,
1368                popup.scroll_offset
1369            );
1370        }
1371    }
1372
1373    /// Called when the editor buffer loses focus (e.g., switching buffers,
1374    /// opening prompts/menus, focusing file explorer, etc.)
1375    ///
1376    /// This is the central handler for focus loss that:
1377    /// - Dismisses transient popups (Hover, Signature Help)
1378    /// - Clears LSP hover state and pending requests
1379    /// - Removes hover symbol highlighting
1380    pub(super) fn on_editor_focus_lost(&mut self) {
1381        // Dismiss transient popups via EditorState
1382        self.active_state_mut().on_focus_lost();
1383
1384        // Clear hover state
1385        self.mouse_state.lsp_hover_state = None;
1386        self.mouse_state.lsp_hover_request_sent = false;
1387        self.pending_hover_request = None;
1388
1389        // Clear hover symbol highlight if present
1390        if let Some(handle) = self.hover_symbol_overlay.take() {
1391            let remove_overlay_event = crate::model::event::Event::RemoveOverlay { handle };
1392            self.apply_event_to_active_buffer(&remove_overlay_event);
1393        }
1394        self.hover_symbol_range = None;
1395    }
1396
1397    /// Clear all popups
1398    pub fn clear_popups(&mut self) {
1399        let event = Event::ClearPopups;
1400        self.active_event_log_mut().append(event.clone());
1401        self.apply_event_to_active_buffer(&event);
1402    }
1403
1404    // === LSP Confirmation Popup ===
1405
1406    /// Show the LSP confirmation popup for a language server
1407    ///
1408    /// This displays a centered popup asking the user to confirm whether
1409    /// they want to start the LSP server for the given language.
1410    pub fn show_lsp_confirmation_popup(&mut self, language: &str) {
1411        use crate::model::event::{
1412            PopupContentData, PopupData, PopupListItemData, PopupPositionData,
1413        };
1414
1415        // Store the pending confirmation
1416        self.pending_lsp_confirmation = Some(language.to_string());
1417
1418        // Get the server command for display
1419        let server_info = if let Some(lsp) = &self.lsp {
1420            if let Some(config) = lsp.get_config(language) {
1421                if !config.command.is_empty() {
1422                    format!("{} ({})", language, config.command)
1423                } else {
1424                    language.to_string()
1425                }
1426            } else {
1427                language.to_string()
1428            }
1429        } else {
1430            language.to_string()
1431        };
1432
1433        let popup = PopupData {
1434            title: Some(format!("Start LSP Server: {}?", server_info)),
1435            description: None,
1436            transient: false,
1437            content: PopupContentData::List {
1438                items: vec![
1439                    PopupListItemData {
1440                        text: "Allow this time".to_string(),
1441                        detail: Some("Start the LSP server for this session".to_string()),
1442                        icon: None,
1443                        data: Some("allow_once".to_string()),
1444                    },
1445                    PopupListItemData {
1446                        text: "Always allow".to_string(),
1447                        detail: Some("Always start this LSP server automatically".to_string()),
1448                        icon: None,
1449                        data: Some("allow_always".to_string()),
1450                    },
1451                    PopupListItemData {
1452                        text: "Don't start".to_string(),
1453                        detail: Some("Cancel LSP server startup".to_string()),
1454                        icon: None,
1455                        data: Some("deny".to_string()),
1456                    },
1457                ],
1458                selected: 0,
1459            },
1460            position: PopupPositionData::Centered,
1461            width: 50,
1462            max_height: 8,
1463            bordered: true,
1464        };
1465
1466        self.show_popup(popup);
1467    }
1468
1469    /// Handle the LSP confirmation popup response
1470    ///
1471    /// This is called when the user confirms their selection in the LSP
1472    /// confirmation popup. It processes the response and starts the LSP
1473    /// server if approved.
1474    ///
1475    /// Returns true if a response was handled, false if there was no pending confirmation.
1476    pub fn handle_lsp_confirmation_response(&mut self, action: &str) -> bool {
1477        let Some(language) = self.pending_lsp_confirmation.take() else {
1478            return false;
1479        };
1480
1481        match action {
1482            "allow_once" => {
1483                // Spawn the LSP server just this once (don't add to always-allowed)
1484                if let Some(lsp) = &mut self.lsp {
1485                    // Temporarily allow this language for spawning
1486                    lsp.allow_language(&language);
1487                    // Use force_spawn since user explicitly confirmed
1488                    if lsp.force_spawn(&language).is_some() {
1489                        tracing::info!("LSP server for {} started (allowed once)", language);
1490                        self.set_status_message(
1491                            t!("lsp.server_started", language = language).to_string(),
1492                        );
1493                    } else {
1494                        self.set_status_message(
1495                            t!("lsp.failed_to_start", language = language).to_string(),
1496                        );
1497                    }
1498                }
1499                // Notify LSP about the current file
1500                self.notify_lsp_current_file_opened(&language);
1501            }
1502            "allow_always" => {
1503                // Spawn the LSP server and remember the preference
1504                if let Some(lsp) = &mut self.lsp {
1505                    lsp.allow_language(&language);
1506                    // Use force_spawn since user explicitly confirmed
1507                    if lsp.force_spawn(&language).is_some() {
1508                        tracing::info!("LSP server for {} started (always allowed)", language);
1509                        self.set_status_message(
1510                            t!("lsp.server_started_auto", language = language).to_string(),
1511                        );
1512                    } else {
1513                        self.set_status_message(
1514                            t!("lsp.failed_to_start", language = language).to_string(),
1515                        );
1516                    }
1517                }
1518                // Notify LSP about the current file
1519                self.notify_lsp_current_file_opened(&language);
1520            }
1521            _ => {
1522                // User declined - don't start the server
1523                tracing::info!("LSP server for {} startup declined by user", language);
1524                self.set_status_message(
1525                    t!("lsp.startup_cancelled", language = language).to_string(),
1526                );
1527            }
1528        }
1529
1530        true
1531    }
1532
1533    /// Notify LSP about the currently open file
1534    ///
1535    /// This is called after an LSP server is started to notify it about
1536    /// the current file so it can provide features like diagnostics.
1537    fn notify_lsp_current_file_opened(&mut self, language: &str) {
1538        // Get buffer metadata for the active buffer
1539        let metadata = match self.buffer_metadata.get(&self.active_buffer()) {
1540            Some(m) => m,
1541            None => {
1542                tracing::debug!(
1543                    "notify_lsp_current_file_opened: no metadata for buffer {:?}",
1544                    self.active_buffer()
1545                );
1546                return;
1547            }
1548        };
1549
1550        if !metadata.lsp_enabled {
1551            tracing::debug!("notify_lsp_current_file_opened: LSP disabled for this buffer");
1552            return;
1553        }
1554
1555        // Get the URI (computed once in with_file)
1556        let uri = match metadata.file_uri() {
1557            Some(u) => u.clone(),
1558            None => {
1559                tracing::debug!(
1560                    "notify_lsp_current_file_opened: no URI for buffer (not a file or URI creation failed)"
1561                );
1562                return;
1563            }
1564        };
1565
1566        // Get the file path and verify language matches
1567        let path = match metadata.file_path() {
1568            Some(p) => p,
1569            None => {
1570                tracing::debug!("notify_lsp_current_file_opened: no file path for buffer");
1571                return;
1572            }
1573        };
1574
1575        let file_language = match detect_language(path, &self.config.languages) {
1576            Some(l) => l,
1577            None => {
1578                tracing::debug!(
1579                    "notify_lsp_current_file_opened: no language detected for {:?}",
1580                    path
1581                );
1582                return;
1583            }
1584        };
1585
1586        // Only notify if the file's language matches the LSP server we just started
1587        if file_language != language {
1588            tracing::debug!(
1589                "notify_lsp_current_file_opened: file language {} doesn't match server {}",
1590                file_language,
1591                language
1592            );
1593            return;
1594        }
1595
1596        // Get the buffer text and line count before borrowing lsp
1597        let active_buffer = self.active_buffer();
1598        let (text, line_count) = if let Some(state) = self.buffers.get(&active_buffer) {
1599            let text = match state.buffer.to_string() {
1600                Some(t) => t,
1601                None => {
1602                    tracing::debug!("notify_lsp_current_file_opened: buffer not fully loaded");
1603                    return;
1604                }
1605            };
1606            let line_count = state.buffer.line_count().unwrap_or(1000);
1607            (text, line_count)
1608        } else {
1609            tracing::debug!("notify_lsp_current_file_opened: no buffer state");
1610            return;
1611        };
1612
1613        // Send didOpen to LSP (use force_spawn since this is called after user confirmation)
1614        if let Some(lsp) = &mut self.lsp {
1615            if let Some(client) = lsp.force_spawn(language) {
1616                tracing::info!("Sending didOpen to newly started LSP for: {}", uri.as_str());
1617                if let Err(e) = client.did_open(uri.clone(), text, file_language) {
1618                    tracing::warn!("Failed to send didOpen to LSP: {}", e);
1619                } else {
1620                    tracing::info!("Successfully sent didOpen to LSP after confirmation");
1621
1622                    // Request pull diagnostics
1623                    let previous_result_id = self.diagnostic_result_ids.get(uri.as_str()).cloned();
1624                    let request_id = self.next_lsp_request_id;
1625                    self.next_lsp_request_id += 1;
1626
1627                    if let Err(e) =
1628                        client.document_diagnostic(request_id, uri.clone(), previous_result_id)
1629                    {
1630                        tracing::debug!(
1631                            "Failed to request pull diagnostics (server may not support): {}",
1632                            e
1633                        );
1634                    }
1635
1636                    // Request inlay hints if enabled
1637                    if self.config.editor.enable_inlay_hints {
1638                        let request_id = self.next_lsp_request_id;
1639                        self.next_lsp_request_id += 1;
1640                        self.pending_inlay_hints_request = Some(request_id);
1641
1642                        let last_line = line_count.saturating_sub(1) as u32;
1643                        let last_char = 10000u32;
1644
1645                        if let Err(e) =
1646                            client.inlay_hints(request_id, uri.clone(), 0, 0, last_line, last_char)
1647                        {
1648                            tracing::debug!(
1649                                "Failed to request inlay hints (server may not support): {}",
1650                                e
1651                            );
1652                            self.pending_inlay_hints_request = None;
1653                        }
1654                    }
1655                }
1656            }
1657        }
1658    }
1659
1660    /// Check if there's a pending LSP confirmation
1661    pub fn has_pending_lsp_confirmation(&self) -> bool {
1662        self.pending_lsp_confirmation.is_some()
1663    }
1664
1665    /// Try to get or spawn an LSP handle, showing confirmation popup if needed
1666    ///
1667    /// This is the recommended way to access LSP functionality. It checks if
1668    /// confirmation is required and shows the popup if so.
1669    ///
1670    /// Returns:
1671    /// - `Some(true)` if LSP is ready (handle was already available or spawned)
1672    /// - `Some(false)` if confirmation popup was shown (user needs to respond)
1673    /// - `None` if LSP is not available (disabled, not configured, not auto-start, or failed)
1674    pub fn try_get_lsp_with_confirmation(&mut self, language: &str) -> Option<bool> {
1675        use crate::services::lsp::manager::LspSpawnResult;
1676
1677        let result = {
1678            let lsp = self.lsp.as_mut()?;
1679            lsp.try_spawn(language)
1680        };
1681
1682        match result {
1683            LspSpawnResult::Spawned => Some(true),
1684            LspSpawnResult::NotAutoStart => None, // Not configured for auto-start
1685            LspSpawnResult::Failed => None,
1686        }
1687    }
1688
1689    /// Navigate popup selection (next item)
1690    pub fn popup_select_next(&mut self) {
1691        let event = Event::PopupSelectNext;
1692        self.active_event_log_mut().append(event.clone());
1693        self.apply_event_to_active_buffer(&event);
1694    }
1695
1696    /// Navigate popup selection (previous item)
1697    pub fn popup_select_prev(&mut self) {
1698        let event = Event::PopupSelectPrev;
1699        self.active_event_log_mut().append(event.clone());
1700        self.apply_event_to_active_buffer(&event);
1701    }
1702
1703    /// Navigate popup (page down)
1704    pub fn popup_page_down(&mut self) {
1705        let event = Event::PopupPageDown;
1706        self.active_event_log_mut().append(event.clone());
1707        self.apply_event_to_active_buffer(&event);
1708    }
1709
1710    /// Navigate popup (page up)
1711    pub fn popup_page_up(&mut self) {
1712        let event = Event::PopupPageUp;
1713        self.active_event_log_mut().append(event.clone());
1714        self.apply_event_to_active_buffer(&event);
1715    }
1716
1717    // === LSP Diagnostics Display ===
1718    // NOTE: Diagnostics are now applied automatically via process_async_messages()
1719    // when received from the LSP server asynchronously. No manual polling needed!
1720
1721    /// Collect all LSP text document changes from an event (recursively for batches)
1722    pub(super) fn collect_lsp_changes(&self, event: &Event) -> Vec<TextDocumentContentChangeEvent> {
1723        match event {
1724            Event::Insert { position, text, .. } => {
1725                tracing::trace!(
1726                    "collect_lsp_changes: processing Insert at position {}",
1727                    position
1728                );
1729                // For insert: create a zero-width range at the insertion point
1730                let (line, character) = self
1731                    .active_state()
1732                    .buffer
1733                    .position_to_lsp_position(*position);
1734                let lsp_pos = Position::new(line as u32, character as u32);
1735                let lsp_range = LspRange::new(lsp_pos, lsp_pos);
1736                vec![TextDocumentContentChangeEvent {
1737                    range: Some(lsp_range),
1738                    range_length: None,
1739                    text: text.clone(),
1740                }]
1741            }
1742            Event::Delete { range, .. } => {
1743                tracing::trace!("collect_lsp_changes: processing Delete range {:?}", range);
1744                // For delete: create a range from start to end, send empty string
1745                let (start_line, start_char) = self
1746                    .active_state()
1747                    .buffer
1748                    .position_to_lsp_position(range.start);
1749                let (end_line, end_char) = self
1750                    .active_state()
1751                    .buffer
1752                    .position_to_lsp_position(range.end);
1753                let lsp_range = LspRange::new(
1754                    Position::new(start_line as u32, start_char as u32),
1755                    Position::new(end_line as u32, end_char as u32),
1756                );
1757                vec![TextDocumentContentChangeEvent {
1758                    range: Some(lsp_range),
1759                    range_length: None,
1760                    text: String::new(),
1761                }]
1762            }
1763            Event::Batch { events, .. } => {
1764                // Collect all changes from sub-events into a single vector
1765                // This allows sending all changes in one didChange notification
1766                tracing::trace!(
1767                    "collect_lsp_changes: processing Batch with {} events",
1768                    events.len()
1769                );
1770                let mut all_changes = Vec::new();
1771                for sub_event in events {
1772                    all_changes.extend(self.collect_lsp_changes(sub_event));
1773                }
1774                all_changes
1775            }
1776            _ => Vec::new(), // Ignore cursor movements and other events
1777        }
1778    }
1779
1780    /// Calculate line information for an event (before buffer modification)
1781    /// This provides accurate line numbers for plugin hooks to track changes.
1782    ///
1783    /// ## Design Alternatives for Line Tracking
1784    ///
1785    /// **Approach 1: Re-diff on every edit (VSCode style)**
1786    /// - Store original file content, re-run diff algorithm after each edit
1787    /// - Simpler conceptually, but O(n) per edit for diff computation
1788    /// - Better for complex scenarios (multi-cursor, large batch edits)
1789    ///
1790    /// **Approach 2: Track line shifts (our approach)**
1791    /// - Calculate line info BEFORE applying edit (like LSP does)
1792    /// - Pass `lines_added`/`lines_removed` to plugins via hooks
1793    /// - Plugins shift their stored line numbers accordingly
1794    /// - O(1) per edit, but requires careful bookkeeping
1795    ///
1796    /// We use Approach 2 because:
1797    /// - Matches existing LSP infrastructure (`collect_lsp_changes`)
1798    /// - More efficient for typical editing patterns
1799    /// - Plugins can choose to re-diff if they need more accuracy
1800    ///
1801    pub(super) fn calculate_event_line_info(&self, event: &Event) -> super::types::EventLineInfo {
1802        match event {
1803            Event::Insert { position, text, .. } => {
1804                // Get line number at insert position (from original buffer)
1805                let start_line = self.active_state().buffer.get_line_number(*position);
1806
1807                // Count newlines in inserted text to determine lines added
1808                let lines_added = text.matches('\n').count();
1809                let end_line = start_line + lines_added;
1810
1811                super::types::EventLineInfo {
1812                    start_line,
1813                    end_line,
1814                    line_delta: lines_added as i32,
1815                }
1816            }
1817            Event::Delete {
1818                range,
1819                deleted_text,
1820                ..
1821            } => {
1822                // Get line numbers for the deleted range (from original buffer)
1823                let start_line = self.active_state().buffer.get_line_number(range.start);
1824                let end_line = self.active_state().buffer.get_line_number(range.end);
1825
1826                // Count newlines in deleted text to determine lines removed
1827                let lines_removed = deleted_text.matches('\n').count();
1828
1829                super::types::EventLineInfo {
1830                    start_line,
1831                    end_line,
1832                    line_delta: -(lines_removed as i32),
1833                }
1834            }
1835            Event::Batch { events, .. } => {
1836                // For batches, compute cumulative line info
1837                // This is a simplification - we report the range covering all changes
1838                let mut min_line = usize::MAX;
1839                let mut max_line = 0usize;
1840                let mut total_delta = 0i32;
1841
1842                for sub_event in events {
1843                    let info = self.calculate_event_line_info(sub_event);
1844                    min_line = min_line.min(info.start_line);
1845                    max_line = max_line.max(info.end_line);
1846                    total_delta += info.line_delta;
1847                }
1848
1849                if min_line == usize::MAX {
1850                    min_line = 0;
1851                }
1852
1853                super::types::EventLineInfo {
1854                    start_line: min_line,
1855                    end_line: max_line,
1856                    line_delta: total_delta,
1857                }
1858            }
1859            _ => super::types::EventLineInfo::default(),
1860        }
1861    }
1862
1863    /// Notify LSP of a file save
1864    pub(super) fn notify_lsp_save(&mut self) {
1865        // Check if LSP is enabled for this buffer
1866        let metadata = match self.buffer_metadata.get(&self.active_buffer()) {
1867            Some(m) => m,
1868            None => {
1869                tracing::debug!(
1870                    "notify_lsp_save: no metadata for buffer {:?}",
1871                    self.active_buffer()
1872                );
1873                return;
1874            }
1875        };
1876
1877        if !metadata.lsp_enabled {
1878            tracing::debug!("notify_lsp_save: LSP disabled for this buffer");
1879            return;
1880        }
1881
1882        // Get the URI
1883        let uri = match metadata.file_uri() {
1884            Some(u) => u.clone(),
1885            None => {
1886                tracing::debug!("notify_lsp_save: no URI for buffer");
1887                return;
1888            }
1889        };
1890
1891        // Get the file path for language detection
1892        let path = match metadata.file_path() {
1893            Some(p) => p,
1894            None => {
1895                tracing::debug!("notify_lsp_save: no file path for buffer");
1896                return;
1897            }
1898        };
1899
1900        let language = match detect_language(path, &self.config.languages) {
1901            Some(l) => l,
1902            None => {
1903                tracing::debug!("notify_lsp_save: no language detected for {:?}", path);
1904                return;
1905            }
1906        };
1907
1908        // Get the full text to send with didSave
1909        let full_text = match self.active_state().buffer.to_string() {
1910            Some(t) => t,
1911            None => {
1912                tracing::debug!("notify_lsp_save: buffer not fully loaded");
1913                return;
1914            }
1915        };
1916        tracing::debug!(
1917            "notify_lsp_save: sending didSave to {} (text length: {} bytes)",
1918            uri.as_str(),
1919            full_text.len()
1920        );
1921
1922        // Only send didSave if LSP is already running (respect auto_start setting)
1923        if let Some(lsp) = &mut self.lsp {
1924            use crate::services::lsp::manager::LspSpawnResult;
1925            if lsp.try_spawn(&language) != LspSpawnResult::Spawned {
1926                tracing::debug!(
1927                    "notify_lsp_save: LSP not running for {} (auto_start disabled)",
1928                    language
1929                );
1930                return;
1931            }
1932            if let Some(client) = lsp.get_handle_mut(&language) {
1933                // Send didSave with the full text content
1934                if let Err(e) = client.did_save(uri, Some(full_text)) {
1935                    tracing::warn!("Failed to send didSave to LSP: {}", e);
1936                } else {
1937                    tracing::info!("Successfully sent didSave to LSP");
1938                }
1939            } else {
1940                tracing::warn!("notify_lsp_save: failed to get LSP client for {}", language);
1941            }
1942        } else {
1943            tracing::debug!("notify_lsp_save: no LSP manager available");
1944        }
1945    }
1946
1947    /// Convert an action into a list of events to apply to the active buffer
1948    /// Returns None for actions that don't generate events (like Quit)
1949    pub fn action_to_events(&mut self, action: Action) -> Option<Vec<Event>> {
1950        let tab_size = self.config.editor.tab_size;
1951        let auto_indent = self.config.editor.auto_indent;
1952        let estimated_line_length = self.config.editor.estimated_line_length;
1953
1954        // Get viewport height from SplitViewState (the authoritative source)
1955        let active_split = self.split_manager.active_split();
1956        let viewport_height = self
1957            .split_view_states
1958            .get(&active_split)
1959            .map(|vs| vs.viewport.height)
1960            .unwrap_or(24);
1961
1962        convert_action_to_events(
1963            self.active_state_mut(),
1964            action,
1965            tab_size,
1966            auto_indent,
1967            estimated_line_length,
1968            viewport_height,
1969        )
1970    }
1971
1972    // === Search and Replace Methods ===
1973
1974    /// Clear all search highlights from the active buffer and reset search state
1975    pub(super) fn clear_search_highlights(&mut self) {
1976        self.clear_search_overlays();
1977        // Also clear search state
1978        self.search_state = None;
1979    }
1980
1981    /// Clear only the visual search overlays, preserving search state for F3/Shift+F3
1982    /// This is used when the buffer is modified - highlights become stale but F3 should still work
1983    pub(super) fn clear_search_overlays(&mut self) {
1984        let ns = self.search_namespace.clone();
1985        let state = self.active_state_mut();
1986        state.overlays.clear_namespace(&ns, &mut state.marker_list);
1987    }
1988
1989    /// Update search highlights in visible viewport only (for incremental search)
1990    /// This is called as the user types in the search prompt for real-time feedback
1991    pub(super) fn update_search_highlights(&mut self, query: &str) {
1992        // If query is empty, clear highlights and return
1993        if query.is_empty() {
1994            self.clear_search_highlights();
1995            return;
1996        }
1997
1998        // Get theme colors and search settings before borrowing state
1999        let search_bg = self.theme.search_match_bg;
2000        let search_fg = self.theme.search_match_fg;
2001        let case_sensitive = self.search_case_sensitive;
2002        let whole_word = self.search_whole_word;
2003        let use_regex = self.search_use_regex;
2004        let ns = self.search_namespace.clone();
2005
2006        // Build regex pattern if regex mode is enabled, or escape for literal search
2007        let regex_pattern = if use_regex {
2008            if whole_word {
2009                format!(r"\b{}\b", query)
2010            } else {
2011                query.to_string()
2012            }
2013        } else {
2014            let escaped = regex::escape(query);
2015            if whole_word {
2016                format!(r"\b{}\b", escaped)
2017            } else {
2018                escaped
2019            }
2020        };
2021
2022        // Build regex with case sensitivity
2023        let regex = regex::RegexBuilder::new(&regex_pattern)
2024            .case_insensitive(!case_sensitive)
2025            .build();
2026
2027        let regex = match regex {
2028            Ok(r) => r,
2029            Err(_) => {
2030                // Invalid regex, clear highlights and return
2031                self.clear_search_highlights();
2032                return;
2033            }
2034        };
2035
2036        // Get viewport from active split's SplitViewState
2037        let active_split = self.split_manager.active_split();
2038        let (top_byte, visible_height) = self
2039            .split_view_states
2040            .get(&active_split)
2041            .map(|vs| (vs.viewport.top_byte, vs.viewport.height.saturating_sub(2)))
2042            .unwrap_or((0, 20));
2043
2044        let state = self.active_state_mut();
2045
2046        // Clear any existing search highlights
2047        state.overlays.clear_namespace(&ns, &mut state.marker_list);
2048
2049        // Get the visible content by iterating through visible lines
2050        let visible_start = top_byte;
2051        let mut visible_end = top_byte;
2052
2053        {
2054            let mut line_iter = state.buffer.line_iterator(top_byte, 80);
2055            for _ in 0..visible_height {
2056                if let Some((line_start, line_content)) = line_iter.next_line() {
2057                    visible_end = line_start + line_content.len();
2058                } else {
2059                    break;
2060                }
2061            }
2062        }
2063
2064        // Ensure we don't go past buffer end
2065        visible_end = visible_end.min(state.buffer.len());
2066
2067        // Get the visible text
2068        let visible_text = state.get_text_range(visible_start, visible_end);
2069
2070        // Find all matches using regex
2071        for mat in regex.find_iter(&visible_text) {
2072            let absolute_pos = visible_start + mat.start();
2073            let match_len = mat.end() - mat.start();
2074
2075            // Add overlay for this match
2076            let search_style = ratatui::style::Style::default().fg(search_fg).bg(search_bg);
2077            let overlay = crate::view::overlay::Overlay::with_namespace(
2078                &mut state.marker_list,
2079                absolute_pos..(absolute_pos + match_len),
2080                crate::view::overlay::OverlayFace::Style {
2081                    style: search_style,
2082                },
2083                ns.clone(),
2084            )
2085            .with_priority_value(10); // Priority - above syntax highlighting
2086
2087            state.overlays.add(overlay);
2088        }
2089    }
2090
2091    /// Perform a search and update search state
2092    pub(super) fn perform_search(&mut self, query: &str) {
2093        // Don't clear search highlights here - keep them from incremental search
2094        // They will be cleared when:
2095        // 1. User cancels search (Escape)
2096        // 2. User makes an edit to the buffer
2097        // 3. User starts a new search (update_search_highlights clears old ones)
2098
2099        if query.is_empty() {
2100            self.search_state = None;
2101            self.set_status_message(t!("search.cancelled").to_string());
2102            return;
2103        }
2104
2105        let search_range = self.pending_search_range.take();
2106
2107        // For large files with lazy loading, we need to load the entire buffer
2108        // before searching. This ensures the search can access all content.
2109        // (Issue #657: Search on large plain text files)
2110        let buffer_content = {
2111            let state = self.active_state_mut();
2112            let total_bytes = state.buffer.len();
2113
2114            // Force-load the entire buffer if not already loaded
2115            // get_text_range_mut() handles lazy loading and returns the content
2116            match state.buffer.get_text_range_mut(0, total_bytes) {
2117                Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
2118                Err(e) => {
2119                    tracing::warn!("Failed to load buffer for search: {}", e);
2120                    self.set_status_message(t!("error.buffer_not_loaded").to_string());
2121                    return;
2122                }
2123            }
2124        };
2125
2126        // Get search settings
2127        let case_sensitive = self.search_case_sensitive;
2128        let whole_word = self.search_whole_word;
2129        let use_regex = self.search_use_regex;
2130
2131        // Determine search boundaries
2132        let (search_start, search_end) = if let Some(ref range) = search_range {
2133            (range.start, range.end)
2134        } else {
2135            (0, buffer_content.len())
2136        };
2137
2138        // Build regex pattern
2139        let regex_pattern = if use_regex {
2140            if whole_word {
2141                format!(r"\b{}\b", query)
2142            } else {
2143                query.to_string()
2144            }
2145        } else {
2146            let escaped = regex::escape(query);
2147            if whole_word {
2148                format!(r"\b{}\b", escaped)
2149            } else {
2150                escaped
2151            }
2152        };
2153
2154        // Build regex with case sensitivity
2155        let regex = match regex::RegexBuilder::new(&regex_pattern)
2156            .case_insensitive(!case_sensitive)
2157            .build()
2158        {
2159            Ok(r) => r,
2160            Err(e) => {
2161                self.search_state = None;
2162                self.set_status_message(
2163                    t!("error.invalid_regex", error = e.to_string()).to_string(),
2164                );
2165                return;
2166            }
2167        };
2168
2169        // Find all matches within the search range (store position and length for overlays)
2170        let search_slice = &buffer_content[search_start..search_end];
2171        let match_ranges: Vec<(usize, usize)> = regex
2172            .find_iter(search_slice)
2173            .map(|m| (search_start + m.start(), m.end() - m.start()))
2174            .collect();
2175
2176        if match_ranges.is_empty() {
2177            self.search_state = None;
2178            let msg = if search_range.is_some() {
2179                format!("No matches found for '{}' in selection", query)
2180            } else {
2181                format!("No matches found for '{}'", query)
2182            };
2183            self.set_status_message(msg);
2184            return;
2185        }
2186
2187        // Extract just positions for search_state.matches
2188        let matches: Vec<usize> = match_ranges.iter().map(|(pos, _)| *pos).collect();
2189
2190        // Create overlays for ALL matches (not just visible ones)
2191        // This ensures F3 can find matches outside viewport and markers track through edits
2192        {
2193            let search_bg = self.theme.search_match_bg;
2194            let search_fg = self.theme.search_match_fg;
2195            let ns = self.search_namespace.clone();
2196            let state = self.active_state_mut();
2197
2198            // Clear existing (visible-only) overlays from incremental search
2199            state.overlays.clear_namespace(&ns, &mut state.marker_list);
2200
2201            // Create overlays for all matches
2202            for &(match_pos, match_len) in &match_ranges {
2203                let search_style = ratatui::style::Style::default().fg(search_fg).bg(search_bg);
2204                let overlay = crate::view::overlay::Overlay::with_namespace(
2205                    &mut state.marker_list,
2206                    match_pos..(match_pos + match_len),
2207                    crate::view::overlay::OverlayFace::Style {
2208                        style: search_style,
2209                    },
2210                    ns.clone(),
2211                )
2212                .with_priority_value(10);
2213                state.overlays.add(overlay);
2214            }
2215        }
2216
2217        // Find the first match at or after the current cursor position
2218        let cursor_pos = {
2219            let state = self.active_state();
2220            state.cursors.primary().position
2221        };
2222        let current_match_index = matches
2223            .iter()
2224            .position(|&pos| pos >= cursor_pos)
2225            .unwrap_or(0);
2226
2227        // Move cursor to the first match
2228        let match_pos = matches[current_match_index];
2229        {
2230            let active_split = self.split_manager.active_split();
2231            let active_buffer = self.active_buffer();
2232            let state = self.active_state_mut();
2233            state.cursors.primary_mut().position = match_pos;
2234            state.cursors.primary_mut().anchor = None;
2235            // Ensure cursor is visible - get viewport from SplitViewState
2236            if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
2237                let state = self.buffers.get_mut(&active_buffer).unwrap();
2238                view_state
2239                    .viewport
2240                    .ensure_visible(&mut state.buffer, state.cursors.primary());
2241            }
2242        }
2243
2244        let num_matches = matches.len();
2245
2246        // Update search state
2247        self.search_state = Some(SearchState {
2248            query: query.to_string(),
2249            matches,
2250            current_match_index: Some(current_match_index),
2251            wrap_search: search_range.is_none(), // Only wrap if not searching in selection
2252            search_range,
2253        });
2254
2255        let msg = if self.search_state.as_ref().unwrap().search_range.is_some() {
2256            format!(
2257                "Found {} match{} for '{}' in selection",
2258                num_matches,
2259                if num_matches == 1 { "" } else { "es" },
2260                query
2261            )
2262        } else {
2263            format!(
2264                "Found {} match{} for '{}'",
2265                num_matches,
2266                if num_matches == 1 { "" } else { "es" },
2267                query
2268            )
2269        };
2270        self.set_status_message(msg);
2271    }
2272
2273    /// Get current match positions from search overlays (which use markers that track edits)
2274    /// This ensures positions are always up-to-date even after buffer modifications
2275    fn get_search_match_positions(&self) -> Vec<usize> {
2276        let ns = &self.search_namespace;
2277        let state = self.active_state();
2278
2279        // Get positions from search overlay markers
2280        let mut positions: Vec<usize> = state
2281            .overlays
2282            .all()
2283            .iter()
2284            .filter(|o| o.namespace.as_ref() == Some(ns))
2285            .filter_map(|o| state.marker_list.get_position(o.start_marker))
2286            .collect();
2287
2288        // Sort positions for consistent ordering
2289        positions.sort_unstable();
2290        positions.dedup(); // Remove any duplicates
2291
2292        positions
2293    }
2294
2295    /// Find the next match
2296    pub(super) fn find_next(&mut self) {
2297        // Get current positions from overlay markers (auto-updated with buffer edits)
2298        // Fall back to search_state.matches if no overlays exist (e.g., find_selection_next)
2299        let overlay_positions = self.get_search_match_positions();
2300
2301        if let Some(ref mut search_state) = self.search_state {
2302            // Use overlay positions if they exist and there's no search_range
2303            // (selection-based search uses cached matches to respect range)
2304            let match_positions =
2305                if !overlay_positions.is_empty() && search_state.search_range.is_none() {
2306                    overlay_positions
2307                } else {
2308                    search_state.matches.clone()
2309                };
2310
2311            if match_positions.is_empty() {
2312                return;
2313            }
2314
2315            let current_index = search_state.current_match_index.unwrap_or(0);
2316            let next_index = if current_index + 1 < match_positions.len() {
2317                current_index + 1
2318            } else if search_state.wrap_search {
2319                0 // Wrap to beginning
2320            } else {
2321                self.set_status_message(t!("search.no_matches").to_string());
2322                return;
2323            };
2324
2325            search_state.current_match_index = Some(next_index);
2326            let match_pos = match_positions[next_index];
2327            let matches_len = match_positions.len();
2328
2329            {
2330                let active_split = self.split_manager.active_split();
2331                let active_buffer = self.active_buffer();
2332                let state = self.active_state_mut();
2333                state.cursors.primary_mut().position = match_pos;
2334                state.cursors.primary_mut().anchor = None;
2335                // Ensure cursor is visible - get viewport from SplitViewState
2336                if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
2337                    let state = self.buffers.get_mut(&active_buffer).unwrap();
2338                    view_state
2339                        .viewport
2340                        .ensure_visible(&mut state.buffer, state.cursors.primary());
2341                }
2342            }
2343
2344            self.set_status_message(
2345                t!(
2346                    "search.match_of",
2347                    current = next_index + 1,
2348                    total = matches_len
2349                )
2350                .to_string(),
2351            );
2352        } else {
2353            let find_key = self
2354                .get_keybinding_for_action("find")
2355                .unwrap_or_else(|| "Ctrl+F".to_string());
2356            self.set_status_message(t!("search.no_active", find_key = find_key).to_string());
2357        }
2358    }
2359
2360    /// Find the previous match
2361    pub(super) fn find_previous(&mut self) {
2362        // Get current positions from overlay markers first (auto-updated with buffer edits)
2363        // Fall back to search_state.matches if no overlays exist (e.g., find_selection_previous)
2364        let overlay_positions = self.get_search_match_positions();
2365
2366        if let Some(ref mut search_state) = self.search_state {
2367            // Use overlay positions if:
2368            // 1. They exist (overlays were created)
2369            // 2. There's no search_range (selection-based search uses cached matches to respect range)
2370            let match_positions =
2371                if !overlay_positions.is_empty() && search_state.search_range.is_none() {
2372                    overlay_positions
2373                } else {
2374                    search_state.matches.clone()
2375                };
2376
2377            if match_positions.is_empty() {
2378                return;
2379            }
2380
2381            let current_index = search_state.current_match_index.unwrap_or(0);
2382            let prev_index = if current_index > 0 {
2383                current_index - 1
2384            } else if search_state.wrap_search {
2385                match_positions.len() - 1 // Wrap to end
2386            } else {
2387                self.set_status_message(t!("search.no_matches").to_string());
2388                return;
2389            };
2390
2391            search_state.current_match_index = Some(prev_index);
2392            let match_pos = match_positions[prev_index];
2393            let matches_len = match_positions.len();
2394
2395            {
2396                let active_split = self.split_manager.active_split();
2397                let active_buffer = self.active_buffer();
2398                let state = self.active_state_mut();
2399                state.cursors.primary_mut().position = match_pos;
2400                state.cursors.primary_mut().anchor = None;
2401                // Ensure cursor is visible - get viewport from SplitViewState
2402                if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
2403                    let state = self.buffers.get_mut(&active_buffer).unwrap();
2404                    view_state
2405                        .viewport
2406                        .ensure_visible(&mut state.buffer, state.cursors.primary());
2407                }
2408            }
2409
2410            self.set_status_message(
2411                t!(
2412                    "search.match_of",
2413                    current = prev_index + 1,
2414                    total = matches_len
2415                )
2416                .to_string(),
2417            );
2418        } else {
2419            let find_key = self
2420                .get_keybinding_for_action("find")
2421                .unwrap_or_else(|| "Ctrl+F".to_string());
2422            self.set_status_message(t!("search.no_active", find_key = find_key).to_string());
2423        }
2424    }
2425
2426    /// Find the next occurrence of the current selection (or word under cursor).
2427    /// This is a "quick find" that doesn't require opening the search panel.
2428    /// The search term is stored so subsequent Alt+N/Alt+P/F3 navigation works.
2429    ///
2430    /// If there's already an active search, this continues with the same search term.
2431    /// Otherwise, it starts a new search with the current selection or word under cursor.
2432    pub(super) fn find_selection_next(&mut self) {
2433        // If there's already a search active AND cursor is at a match position,
2434        // just continue to next match. Otherwise, clear and start fresh.
2435        if let Some(ref search_state) = self.search_state {
2436            let cursor_pos = self.active_state().cursors.primary().position;
2437            if search_state.matches.contains(&cursor_pos) {
2438                self.find_next();
2439                return;
2440            }
2441            // Cursor moved away from a match - clear search state
2442        }
2443        self.search_state = None;
2444
2445        // No active search - start a new one with selection or word under cursor
2446        let (search_text, selection_start) = self.get_selection_or_word_for_search_with_pos();
2447
2448        match search_text {
2449            Some(text) if !text.is_empty() => {
2450                // Record cursor position before search
2451                let cursor_before = self.active_state().cursors.primary().position;
2452
2453                // Perform the search to set up search state
2454                self.perform_search(&text);
2455
2456                // Check if we need to move to next match
2457                if let Some(ref search_state) = self.search_state {
2458                    let cursor_after = self.active_state().cursors.primary().position;
2459
2460                    // If we started at a match (selection_start matches a search result),
2461                    // and perform_search didn't move us (or moved us to the same match),
2462                    // then we need to find_next
2463                    let started_at_match = selection_start
2464                        .map(|start| search_state.matches.contains(&start))
2465                        .unwrap_or(false);
2466
2467                    let landed_at_start = selection_start
2468                        .map(|start| cursor_after == start)
2469                        .unwrap_or(false);
2470
2471                    // Only call find_next if:
2472                    // 1. We started at a match AND landed back at it, OR
2473                    // 2. We didn't move at all
2474                    if ((started_at_match && landed_at_start) || cursor_before == cursor_after)
2475                        && search_state.matches.len() > 1
2476                    {
2477                        self.find_next();
2478                    }
2479                }
2480            }
2481            _ => {
2482                self.set_status_message(t!("search.no_text").to_string());
2483            }
2484        }
2485    }
2486
2487    /// Find the previous occurrence of the current selection (or word under cursor).
2488    /// This is a "quick find" that doesn't require opening the search panel.
2489    ///
2490    /// If there's already an active search, this continues with the same search term.
2491    /// Otherwise, it starts a new search with the current selection or word under cursor.
2492    pub(super) fn find_selection_previous(&mut self) {
2493        // If there's already a search active AND cursor is at a match position,
2494        // just continue to previous match. Otherwise, clear and start fresh.
2495        if let Some(ref search_state) = self.search_state {
2496            let cursor_pos = self.active_state().cursors.primary().position;
2497            if search_state.matches.contains(&cursor_pos) {
2498                self.find_previous();
2499                return;
2500            }
2501            // Cursor moved away from a match - clear search state
2502        }
2503        self.search_state = None;
2504
2505        // No active search - start a new one with selection or word under cursor
2506        let (search_text, selection_start) = self.get_selection_or_word_for_search_with_pos();
2507
2508        match search_text {
2509            Some(text) if !text.is_empty() => {
2510                // Record cursor position before search
2511                let cursor_before = self.active_state().cursors.primary().position;
2512
2513                // Perform the search to set up search state
2514                self.perform_search(&text);
2515
2516                // If we found matches, navigate to previous
2517                if let Some(ref search_state) = self.search_state {
2518                    let cursor_after = self.active_state().cursors.primary().position;
2519
2520                    // Check if we started at a match
2521                    let started_at_match = selection_start
2522                        .map(|start| search_state.matches.contains(&start))
2523                        .unwrap_or(false);
2524
2525                    let landed_at_start = selection_start
2526                        .map(|start| cursor_after == start)
2527                        .unwrap_or(false);
2528
2529                    // For find previous, we always need to call find_previous at least once.
2530                    // If we landed at our starting match, we need to go back once to get previous.
2531                    // If we landed at a different match (because cursor was past start of selection),
2532                    // we still want to find_previous to get to where we should be.
2533                    if started_at_match && landed_at_start {
2534                        // We're at the same match we started at, go to previous
2535                        self.find_previous();
2536                    } else if cursor_before != cursor_after {
2537                        // perform_search moved us, now go back to find the actual previous
2538                        // from our original position (which is before where we landed)
2539                        self.find_previous();
2540                    } else {
2541                        // Cursor didn't move, just find previous
2542                        self.find_previous();
2543                    }
2544                }
2545            }
2546            _ => {
2547                self.set_status_message(t!("search.no_text").to_string());
2548            }
2549        }
2550    }
2551
2552    /// Get the text to search for from selection or word under cursor,
2553    /// along with the start position of that text (for determining if we're at a match).
2554    fn get_selection_or_word_for_search_with_pos(&mut self) -> (Option<String>, Option<usize>) {
2555        use crate::primitives::word_navigation::{find_word_end, find_word_start};
2556
2557        // First get selection range and cursor position with immutable borrow
2558        let (selection_range, cursor_pos) = {
2559            let state = self.active_state();
2560            let primary = state.cursors.primary();
2561            (primary.selection_range(), primary.position)
2562        };
2563
2564        // Check if there's a selection
2565        if let Some(range) = selection_range {
2566            let state = self.active_state_mut();
2567            let text = state.get_text_range(range.start, range.end);
2568            if !text.is_empty() {
2569                return (Some(text), Some(range.start));
2570            }
2571        }
2572
2573        // No selection - try to get word under cursor
2574        let (word_start, word_end) = {
2575            let state = self.active_state();
2576            let word_start = find_word_start(&state.buffer, cursor_pos);
2577            let word_end = find_word_end(&state.buffer, cursor_pos);
2578            (word_start, word_end)
2579        };
2580
2581        if word_start < word_end {
2582            let state = self.active_state_mut();
2583            (
2584                Some(state.get_text_range(word_start, word_end)),
2585                Some(word_start),
2586            )
2587        } else {
2588            (None, None)
2589        }
2590    }
2591
2592    /// Perform a replace-all operation
2593    /// Replaces all occurrences of the search query with the replacement text
2594    ///
2595    /// OPTIMIZATION: Uses BulkEdit for O(n) tree operations instead of O(n²)
2596    /// This directly edits the piece tree without loading the entire buffer into memory
2597    pub(super) fn perform_replace(&mut self, search: &str, replacement: &str) {
2598        if search.is_empty() {
2599            self.set_status_message(t!("replace.empty_query").to_string());
2600            return;
2601        }
2602
2603        // Find all matches first (before making any modifications)
2604        let matches = {
2605            let state = self.active_state();
2606            let buffer_len = state.buffer.len();
2607            let mut matches = Vec::new();
2608            let mut current_pos = 0;
2609
2610            while current_pos < buffer_len {
2611                if let Some(offset) = state.buffer.find_next_in_range(
2612                    search,
2613                    current_pos,
2614                    Some(current_pos..buffer_len),
2615                ) {
2616                    matches.push(offset);
2617                    current_pos = offset + search.len();
2618                } else {
2619                    break;
2620                }
2621            }
2622            matches
2623        };
2624
2625        let count = matches.len();
2626
2627        if count == 0 {
2628            self.set_status_message(t!("search.no_occurrences", search = search).to_string());
2629            return;
2630        }
2631
2632        // Get cursor info for the event
2633        let cursor_id = self.active_state().cursors.primary_id();
2634
2635        // Create Delete+Insert events for each match
2636        // Events will be processed in reverse order by apply_events_as_bulk_edit
2637        let mut events = Vec::with_capacity(count * 2);
2638        for &match_pos in &matches {
2639            // Delete the matched text
2640            events.push(Event::Delete {
2641                range: match_pos..match_pos + search.len(),
2642                deleted_text: search.to_string(), // We know what text is being deleted
2643                cursor_id,
2644            });
2645            // Insert the replacement
2646            events.push(Event::Insert {
2647                position: match_pos,
2648                text: replacement.to_string(),
2649                cursor_id,
2650            });
2651        }
2652
2653        // Apply all replacements using BulkEdit for O(n) performance
2654        let description = format!("Replace all '{}' with '{}'", search, replacement);
2655        if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description) {
2656            self.active_event_log_mut().append(bulk_edit);
2657        }
2658
2659        // Clear search state since positions are now invalid
2660        self.search_state = None;
2661
2662        // Clear any search highlight overlays
2663        let ns = self.search_namespace.clone();
2664        let state = self.active_state_mut();
2665        state.overlays.clear_namespace(&ns, &mut state.marker_list);
2666
2667        // Set status message
2668        self.set_status_message(
2669            t!(
2670                "search.replaced",
2671                count = count,
2672                search = search,
2673                replace = replacement
2674            )
2675            .to_string(),
2676        );
2677    }
2678
2679    /// Start interactive replace mode (query-replace)
2680    pub(super) fn start_interactive_replace(&mut self, search: &str, replacement: &str) {
2681        if search.is_empty() {
2682            self.set_status_message(t!("replace.query_empty").to_string());
2683            return;
2684        }
2685
2686        // Find the first match lazily (don't find all matches upfront)
2687        let state = self.active_state();
2688        let start_pos = state.cursors.primary().position;
2689        let first_match = state.buffer.find_next(search, start_pos);
2690
2691        let Some(first_match_pos) = first_match else {
2692            self.set_status_message(t!("search.no_occurrences", search = search).to_string());
2693            return;
2694        };
2695
2696        // Initialize interactive replace state with just the current match
2697        self.interactive_replace_state = Some(InteractiveReplaceState {
2698            search: search.to_string(),
2699            replacement: replacement.to_string(),
2700            current_match_pos: first_match_pos,
2701            start_pos: first_match_pos,
2702            has_wrapped: false,
2703            replacements_made: 0,
2704        });
2705
2706        // Move cursor to first match
2707        let active_split = self.split_manager.active_split();
2708        let active_buffer = self.active_buffer();
2709        {
2710            let state = self.active_state_mut();
2711            state.cursors.primary_mut().position = first_match_pos;
2712            state.cursors.primary_mut().anchor = None;
2713        }
2714        // Ensure cursor is visible - get viewport from SplitViewState
2715        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
2716            let state = self.buffers.get_mut(&active_buffer).unwrap();
2717            view_state
2718                .viewport
2719                .ensure_visible(&mut state.buffer, state.cursors.primary());
2720        }
2721
2722        // Show the query-replace prompt
2723        self.prompt = Some(Prompt::new(
2724            "Replace? (y)es (n)o (a)ll (c)ancel: ".to_string(),
2725            PromptType::QueryReplaceConfirm,
2726        ));
2727    }
2728
2729    /// Handle interactive replace key press (y/n/a/c)
2730    pub(super) fn handle_interactive_replace_key(&mut self, c: char) -> AnyhowResult<()> {
2731        let state = self.interactive_replace_state.clone();
2732        let Some(mut ir_state) = state else {
2733            return Ok(());
2734        };
2735
2736        match c {
2737            'y' | 'Y' => {
2738                // Replace current match
2739                self.replace_current_match(&ir_state)?;
2740                ir_state.replacements_made += 1;
2741
2742                // Find next match lazily (after the replacement)
2743                let search_pos = ir_state.current_match_pos + ir_state.replacement.len();
2744                if let Some((next_match, wrapped)) =
2745                    self.find_next_match_for_replace(&ir_state, search_pos)
2746                {
2747                    ir_state.current_match_pos = next_match;
2748                    if wrapped {
2749                        ir_state.has_wrapped = true;
2750                    }
2751                    self.interactive_replace_state = Some(ir_state.clone());
2752                    self.move_to_current_match(&ir_state);
2753                } else {
2754                    self.finish_interactive_replace(ir_state.replacements_made);
2755                }
2756            }
2757            'n' | 'N' => {
2758                // Skip current match and find next
2759                let search_pos = ir_state.current_match_pos + ir_state.search.len();
2760                if let Some((next_match, wrapped)) =
2761                    self.find_next_match_for_replace(&ir_state, search_pos)
2762                {
2763                    ir_state.current_match_pos = next_match;
2764                    if wrapped {
2765                        ir_state.has_wrapped = true;
2766                    }
2767                    self.interactive_replace_state = Some(ir_state.clone());
2768                    self.move_to_current_match(&ir_state);
2769                } else {
2770                    self.finish_interactive_replace(ir_state.replacements_made);
2771                }
2772            }
2773            'a' | 'A' | '!' => {
2774                // Replace all remaining matches with SINGLE confirmation
2775                // Undo behavior: ONE undo step undoes ALL remaining replacements
2776                //
2777                // OPTIMIZATION: Uses BulkEdit for O(n) tree operations instead of O(n²)
2778                // This directly edits the piece tree without loading the entire buffer
2779
2780                // Collect ALL match positions including the current match
2781                // Start from the current match position
2782                let all_matches = {
2783                    let mut matches = Vec::new();
2784                    let mut temp_state = ir_state.clone();
2785                    temp_state.has_wrapped = false; // Reset wrap state to find current match
2786
2787                    // First, include the current match
2788                    matches.push(ir_state.current_match_pos);
2789                    let mut current_pos = ir_state.current_match_pos + ir_state.search.len();
2790
2791                    // Find all remaining matches
2792                    while let Some((next_match, wrapped)) =
2793                        self.find_next_match_for_replace(&temp_state, current_pos)
2794                    {
2795                        matches.push(next_match);
2796                        current_pos = next_match + temp_state.search.len();
2797                        if wrapped {
2798                            temp_state.has_wrapped = true;
2799                        }
2800                    }
2801                    matches
2802                };
2803
2804                let total_count = all_matches.len();
2805
2806                if total_count > 0 {
2807                    // Get cursor info for the event
2808                    let cursor_id = self.active_state().cursors.primary_id();
2809
2810                    // Create Delete+Insert events for each match
2811                    let mut events = Vec::with_capacity(total_count * 2);
2812                    for &match_pos in &all_matches {
2813                        events.push(Event::Delete {
2814                            range: match_pos..match_pos + ir_state.search.len(),
2815                            deleted_text: ir_state.search.clone(),
2816                            cursor_id,
2817                        });
2818                        events.push(Event::Insert {
2819                            position: match_pos,
2820                            text: ir_state.replacement.clone(),
2821                            cursor_id,
2822                        });
2823                    }
2824
2825                    // Apply all replacements using BulkEdit for O(n) performance
2826                    let description = format!(
2827                        "Replace all {} occurrences of '{}' with '{}'",
2828                        total_count, ir_state.search, ir_state.replacement
2829                    );
2830                    if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description) {
2831                        self.active_event_log_mut().append(bulk_edit);
2832                    }
2833
2834                    ir_state.replacements_made += total_count;
2835                }
2836
2837                self.finish_interactive_replace(ir_state.replacements_made);
2838            }
2839            'c' | 'C' | 'q' | 'Q' | '\x1b' => {
2840                // Cancel/quit interactive replace
2841                self.finish_interactive_replace(ir_state.replacements_made);
2842            }
2843            _ => {
2844                // Unknown key - ignored (prompt shows valid options)
2845            }
2846        }
2847
2848        Ok(())
2849    }
2850
2851    /// Find the next match for interactive replace (lazy search with wrap-around)
2852    pub(super) fn find_next_match_for_replace(
2853        &self,
2854        ir_state: &InteractiveReplaceState,
2855        start_pos: usize,
2856    ) -> Option<(usize, bool)> {
2857        let state = self.active_state();
2858
2859        if ir_state.has_wrapped {
2860            // We've already wrapped - only search from start_pos up to (but not including) the original start position
2861            // Use find_next_in_range to avoid wrapping again
2862            let search_range = Some(start_pos..ir_state.start_pos);
2863            if let Some(match_pos) =
2864                state
2865                    .buffer
2866                    .find_next_in_range(&ir_state.search, start_pos, search_range)
2867            {
2868                return Some((match_pos, true));
2869            }
2870            None // No more matches before original start position
2871        } else {
2872            // Haven't wrapped yet - search normally from start_pos
2873            // First try from start_pos to end of buffer
2874            let buffer_len = state.buffer.len();
2875            let search_range = Some(start_pos..buffer_len);
2876            if let Some(match_pos) =
2877                state
2878                    .buffer
2879                    .find_next_in_range(&ir_state.search, start_pos, search_range)
2880            {
2881                return Some((match_pos, false));
2882            }
2883
2884            // No match from start_pos to end - wrap to beginning
2885            // Search from 0 to start_pos (original position)
2886            let wrap_range = Some(0..ir_state.start_pos);
2887            if let Some(match_pos) =
2888                state
2889                    .buffer
2890                    .find_next_in_range(&ir_state.search, 0, wrap_range)
2891            {
2892                return Some((match_pos, true)); // Found match after wrapping
2893            }
2894
2895            None // No matches found anywhere
2896        }
2897    }
2898
2899    /// Replace the current match in interactive replace mode
2900    pub(super) fn replace_current_match(
2901        &mut self,
2902        ir_state: &InteractiveReplaceState,
2903    ) -> AnyhowResult<()> {
2904        let match_pos = ir_state.current_match_pos;
2905        let search_len = ir_state.search.len();
2906        let range = match_pos..(match_pos + search_len);
2907
2908        // Get the deleted text for the event
2909        let deleted_text = self
2910            .active_state_mut()
2911            .get_text_range(range.start, range.end);
2912
2913        // Capture current cursor state for undo
2914        let cursor_id = self.active_state().cursors.primary_id();
2915        let cursor = *self.active_state().cursors.get(cursor_id).unwrap();
2916        let old_position = cursor.position;
2917        let old_anchor = cursor.anchor;
2918        let old_sticky_column = cursor.sticky_column;
2919
2920        // Create events: MoveCursor, Delete, Insert
2921        // The MoveCursor saves the cursor position so undo can restore it
2922        let events = vec![
2923            Event::MoveCursor {
2924                cursor_id,
2925                old_position,
2926                new_position: match_pos,
2927                old_anchor,
2928                new_anchor: None,
2929                old_sticky_column,
2930                new_sticky_column: 0,
2931            },
2932            Event::Delete {
2933                range: range.clone(),
2934                deleted_text,
2935                cursor_id,
2936            },
2937            Event::Insert {
2938                position: match_pos,
2939                text: ir_state.replacement.clone(),
2940                cursor_id,
2941            },
2942        ];
2943
2944        // Wrap in batch for atomic undo
2945        let batch = Event::Batch {
2946            events,
2947            description: format!(
2948                "Query replace '{}' with '{}'",
2949                ir_state.search, ir_state.replacement
2950            ),
2951        };
2952
2953        // Apply the batch through the event log
2954        self.active_event_log_mut().append(batch.clone());
2955        self.apply_event_to_active_buffer(&batch);
2956
2957        Ok(())
2958    }
2959
2960    /// Move cursor to the current match in interactive replace
2961    pub(super) fn move_to_current_match(&mut self, ir_state: &InteractiveReplaceState) {
2962        let match_pos = ir_state.current_match_pos;
2963        let active_split = self.split_manager.active_split();
2964        let active_buffer = self.active_buffer();
2965        {
2966            let state = self.active_state_mut();
2967            state.cursors.primary_mut().position = match_pos;
2968            state.cursors.primary_mut().anchor = None;
2969        }
2970        // Ensure cursor is visible - get viewport from SplitViewState
2971        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
2972            let state = self.buffers.get_mut(&active_buffer).unwrap();
2973            view_state
2974                .viewport
2975                .ensure_visible(&mut state.buffer, state.cursors.primary());
2976        }
2977
2978        // Update the prompt message (show [Wrapped] if we've wrapped around)
2979        let msg = if ir_state.has_wrapped {
2980            "[Wrapped] Replace? (y)es (n)o (a)ll (c)ancel: ".to_string()
2981        } else {
2982            "Replace? (y)es (n)o (a)ll (c)ancel: ".to_string()
2983        };
2984        if let Some(ref mut prompt) = self.prompt {
2985            if prompt.prompt_type == PromptType::QueryReplaceConfirm {
2986                prompt.message = msg;
2987                prompt.input.clear();
2988                prompt.cursor_pos = 0;
2989            }
2990        }
2991    }
2992
2993    /// Finish interactive replace and show summary
2994    pub(super) fn finish_interactive_replace(&mut self, replacements_made: usize) {
2995        self.interactive_replace_state = None;
2996        self.prompt = None; // Clear the query-replace prompt
2997
2998        // Clear search highlights
2999        let ns = self.search_namespace.clone();
3000        let state = self.active_state_mut();
3001        state.overlays.clear_namespace(&ns, &mut state.marker_list);
3002
3003        self.set_status_message(t!("search.replaced_count", count = replacements_made).to_string());
3004    }
3005
3006    /// Smart home: toggle between line start and first non-whitespace character
3007    pub(super) fn smart_home(&mut self) {
3008        let estimated_line_length = self.config.editor.estimated_line_length;
3009        let state = self.active_state_mut();
3010        let cursor = *state.cursors.primary();
3011        let cursor_id = state.cursors.primary_id();
3012
3013        // Get line information
3014        let mut iter = state
3015            .buffer
3016            .line_iterator(cursor.position, estimated_line_length);
3017        if let Some((line_start, line_content)) = iter.next_line() {
3018            // Find first non-whitespace character
3019            let first_non_ws = line_content
3020                .chars()
3021                .take_while(|c| *c != '\n')
3022                .position(|c| !c.is_whitespace())
3023                .map(|offset| line_start + offset)
3024                .unwrap_or(line_start);
3025
3026            // Toggle: if at first non-ws, go to line start; otherwise go to first non-ws
3027            let new_pos = if cursor.position == first_non_ws {
3028                line_start
3029            } else {
3030                first_non_ws
3031            };
3032
3033            let event = Event::MoveCursor {
3034                cursor_id,
3035                old_position: cursor.position,
3036                new_position: new_pos,
3037                old_anchor: cursor.anchor,
3038                new_anchor: None,
3039                old_sticky_column: cursor.sticky_column,
3040                new_sticky_column: 0,
3041            };
3042
3043            self.active_event_log_mut().append(event.clone());
3044            self.apply_event_to_active_buffer(&event);
3045        }
3046    }
3047
3048    /// Toggle comment on the current line or selection
3049    pub(super) fn toggle_comment(&mut self) {
3050        // Determine comment prefix from language config
3051        // If no language detected or no comment prefix configured, do nothing
3052        let language = &self.active_state().language;
3053        let comment_prefix = self
3054            .config
3055            .languages
3056            .get(language)
3057            .and_then(|lang_config| lang_config.comment_prefix.clone());
3058
3059        let comment_prefix: String = match comment_prefix {
3060            Some(prefix) => {
3061                // Ensure there's a trailing space for consistent formatting
3062                if prefix.ends_with(' ') {
3063                    prefix
3064                } else {
3065                    format!("{} ", prefix)
3066                }
3067            }
3068            None => return, // No comment prefix for this language, do nothing
3069        };
3070
3071        let estimated_line_length = self.config.editor.estimated_line_length;
3072
3073        let state = self.active_state_mut();
3074        let cursor = *state.cursors.primary();
3075        let cursor_id = state.cursors.primary_id();
3076
3077        // Save original selection info to restore after edit
3078        let original_anchor = cursor.anchor;
3079        let original_position = cursor.position;
3080        let had_selection = original_anchor.is_some();
3081
3082        let (start_pos, end_pos) = if let Some(range) = cursor.selection_range() {
3083            (range.start, range.end)
3084        } else {
3085            let iter = state
3086                .buffer
3087                .line_iterator(cursor.position, estimated_line_length);
3088            let line_start = iter.current_position();
3089            (line_start, cursor.position)
3090        };
3091
3092        // Find all line starts in the range
3093        let buffer_len = state.buffer.len();
3094        let mut line_starts = Vec::new();
3095        let mut iter = state.buffer.line_iterator(start_pos, estimated_line_length);
3096        let mut current_pos = iter.current_position();
3097        line_starts.push(current_pos);
3098
3099        while let Some((_, content)) = iter.next_line() {
3100            current_pos += content.len();
3101            if current_pos >= end_pos || current_pos >= buffer_len {
3102                break;
3103            }
3104            let next_iter = state
3105                .buffer
3106                .line_iterator(current_pos, estimated_line_length);
3107            let next_start = next_iter.current_position();
3108            if next_start != *line_starts.last().unwrap() {
3109                line_starts.push(next_start);
3110            }
3111            iter = state
3112                .buffer
3113                .line_iterator(current_pos, estimated_line_length);
3114        }
3115
3116        // Determine if we should comment or uncomment
3117        // If all lines are commented, uncomment; otherwise comment
3118        let all_commented = line_starts.iter().all(|&line_start| {
3119            let line_bytes = state
3120                .buffer
3121                .slice_bytes(line_start..buffer_len.min(line_start + comment_prefix.len() + 10));
3122            let line_str = String::from_utf8_lossy(&line_bytes);
3123            let trimmed = line_str.trim_start();
3124            trimmed.starts_with(comment_prefix.trim())
3125        });
3126
3127        let mut events = Vec::new();
3128        // Track (edit_position, byte_delta) for calculating new cursor positions
3129        // delta is positive for insertions, negative for deletions
3130        let mut position_deltas: Vec<(usize, isize)> = Vec::new();
3131
3132        if all_commented {
3133            // Uncomment: remove comment prefix from each line
3134            for &line_start in line_starts.iter().rev() {
3135                let line_bytes = state
3136                    .buffer
3137                    .slice_bytes(line_start..buffer_len.min(line_start + 100));
3138                let line_str = String::from_utf8_lossy(&line_bytes);
3139
3140                // Find where the comment prefix starts (after leading whitespace)
3141                let leading_ws: usize = line_str
3142                    .chars()
3143                    .take_while(|c| c.is_whitespace() && *c != '\n')
3144                    .map(|c| c.len_utf8())
3145                    .sum();
3146                let rest = &line_str[leading_ws..];
3147
3148                if rest.starts_with(comment_prefix.trim()) {
3149                    let remove_len = if rest.starts_with(&comment_prefix) {
3150                        comment_prefix.len()
3151                    } else {
3152                        comment_prefix.trim().len()
3153                    };
3154                    let deleted_text = String::from_utf8_lossy(&state.buffer.slice_bytes(
3155                        line_start + leading_ws..line_start + leading_ws + remove_len,
3156                    ))
3157                    .to_string();
3158                    events.push(Event::Delete {
3159                        range: (line_start + leading_ws)..(line_start + leading_ws + remove_len),
3160                        deleted_text,
3161                        cursor_id,
3162                    });
3163                    position_deltas.push((line_start, -(remove_len as isize)));
3164                }
3165            }
3166        } else {
3167            // Comment: add comment prefix to each line
3168            let prefix_len = comment_prefix.len();
3169            for &line_start in line_starts.iter().rev() {
3170                events.push(Event::Insert {
3171                    position: line_start,
3172                    text: comment_prefix.to_string(),
3173                    cursor_id,
3174                });
3175                position_deltas.push((line_start, prefix_len as isize));
3176            }
3177        }
3178
3179        if events.is_empty() {
3180            return;
3181        }
3182
3183        let action_desc = if all_commented {
3184            "Uncomment"
3185        } else {
3186            "Comment"
3187        };
3188
3189        // If there was a selection, add a MoveCursor event to restore it
3190        if had_selection {
3191            // Sort deltas by position ascending for calculation
3192            position_deltas.sort_by_key(|(pos, _)| *pos);
3193
3194            // Calculate cumulative shift for a position based on edits at or before that position
3195            let calc_shift = |original_pos: usize| -> isize {
3196                let mut shift: isize = 0;
3197                for (edit_pos, delta) in &position_deltas {
3198                    if *edit_pos < original_pos {
3199                        shift += delta;
3200                    }
3201                }
3202                shift
3203            };
3204
3205            let anchor_shift = calc_shift(original_anchor.unwrap_or(0));
3206            let position_shift = calc_shift(original_position);
3207
3208            let new_anchor = (original_anchor.unwrap_or(0) as isize + anchor_shift).max(0) as usize;
3209            let new_position = (original_position as isize + position_shift).max(0) as usize;
3210
3211            events.push(Event::MoveCursor {
3212                cursor_id,
3213                old_position: original_position,
3214                new_position,
3215                old_anchor: original_anchor,
3216                new_anchor: Some(new_anchor),
3217                old_sticky_column: 0,
3218                new_sticky_column: 0,
3219            });
3220        }
3221
3222        // Use optimized bulk edit for multi-line comment toggle
3223        let description = format!("{} lines", action_desc);
3224        if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description) {
3225            self.active_event_log_mut().append(bulk_edit);
3226        }
3227
3228        self.set_status_message(
3229            t!(
3230                "lines.action",
3231                action = action_desc,
3232                count = line_starts.len()
3233            )
3234            .to_string(),
3235        );
3236    }
3237
3238    /// Go to matching bracket
3239    pub(super) fn goto_matching_bracket(&mut self) {
3240        let state = self.active_state_mut();
3241        let cursor = *state.cursors.primary();
3242        let cursor_id = state.cursors.primary_id();
3243
3244        let pos = cursor.position;
3245        if pos >= state.buffer.len() {
3246            self.set_status_message(t!("diagnostics.bracket_none").to_string());
3247            return;
3248        }
3249
3250        let bytes = state.buffer.slice_bytes(pos..pos + 1);
3251        if bytes.is_empty() {
3252            self.set_status_message(t!("diagnostics.bracket_none").to_string());
3253            return;
3254        }
3255
3256        let ch = bytes[0] as char;
3257        let (opening, closing, forward) = match ch {
3258            '(' => ('(', ')', true),
3259            ')' => ('(', ')', false),
3260            '[' => ('[', ']', true),
3261            ']' => ('[', ']', false),
3262            '{' => ('{', '}', true),
3263            '}' => ('{', '}', false),
3264            '<' => ('<', '>', true),
3265            '>' => ('<', '>', false),
3266            _ => {
3267                self.set_status_message(t!("diagnostics.bracket_none").to_string());
3268                return;
3269            }
3270        };
3271
3272        // Find matching bracket
3273        let buffer_len = state.buffer.len();
3274        let mut depth = 1;
3275        let matching_pos = if forward {
3276            let mut search_pos = pos + 1;
3277            let mut found = None;
3278            while search_pos < buffer_len && depth > 0 {
3279                let b = state.buffer.slice_bytes(search_pos..search_pos + 1);
3280                if !b.is_empty() {
3281                    let c = b[0] as char;
3282                    if c == opening {
3283                        depth += 1;
3284                    } else if c == closing {
3285                        depth -= 1;
3286                        if depth == 0 {
3287                            found = Some(search_pos);
3288                        }
3289                    }
3290                }
3291                search_pos += 1;
3292            }
3293            found
3294        } else {
3295            let mut search_pos = pos.saturating_sub(1);
3296            let mut found = None;
3297            loop {
3298                let b = state.buffer.slice_bytes(search_pos..search_pos + 1);
3299                if !b.is_empty() {
3300                    let c = b[0] as char;
3301                    if c == closing {
3302                        depth += 1;
3303                    } else if c == opening {
3304                        depth -= 1;
3305                        if depth == 0 {
3306                            found = Some(search_pos);
3307                            break;
3308                        }
3309                    }
3310                }
3311                if search_pos == 0 {
3312                    break;
3313                }
3314                search_pos -= 1;
3315            }
3316            found
3317        };
3318
3319        if let Some(new_pos) = matching_pos {
3320            let event = Event::MoveCursor {
3321                cursor_id,
3322                old_position: cursor.position,
3323                new_position: new_pos,
3324                old_anchor: cursor.anchor,
3325                new_anchor: None,
3326                old_sticky_column: cursor.sticky_column,
3327                new_sticky_column: 0,
3328            };
3329            self.active_event_log_mut().append(event.clone());
3330            self.apply_event_to_active_buffer(&event);
3331        } else {
3332            self.set_status_message(t!("diagnostics.bracket_no_match").to_string());
3333        }
3334    }
3335
3336    /// Jump to next error/diagnostic
3337    pub(super) fn jump_to_next_error(&mut self) {
3338        let diagnostic_ns = self.lsp_diagnostic_namespace.clone();
3339        let state = self.active_state_mut();
3340        let cursor_pos = state.cursors.primary().position;
3341        let cursor_id = state.cursors.primary_id();
3342        let cursor = *state.cursors.primary();
3343
3344        // Get all diagnostic overlay positions
3345        let mut diagnostic_positions: Vec<usize> = state
3346            .overlays
3347            .all()
3348            .iter()
3349            .filter_map(|overlay| {
3350                // Only consider LSP diagnostics (those in the diagnostic namespace)
3351                if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
3352                    Some(overlay.range(&state.marker_list).start)
3353                } else {
3354                    None
3355                }
3356            })
3357            .collect();
3358
3359        if diagnostic_positions.is_empty() {
3360            self.set_status_message(t!("diagnostics.none").to_string());
3361            return;
3362        }
3363
3364        // Sort positions
3365        diagnostic_positions.sort_unstable();
3366        diagnostic_positions.dedup();
3367
3368        // Find next diagnostic after cursor position
3369        let next_pos = diagnostic_positions
3370            .iter()
3371            .find(|&&pos| pos > cursor_pos)
3372            .or_else(|| diagnostic_positions.first()) // Wrap around
3373            .copied();
3374
3375        if let Some(new_pos) = next_pos {
3376            let event = Event::MoveCursor {
3377                cursor_id,
3378                old_position: cursor.position,
3379                new_position: new_pos,
3380                old_anchor: cursor.anchor,
3381                new_anchor: None,
3382                old_sticky_column: cursor.sticky_column,
3383                new_sticky_column: 0,
3384            };
3385            self.active_event_log_mut().append(event.clone());
3386            self.apply_event_to_active_buffer(&event);
3387
3388            // Show diagnostic message in status bar
3389            let state = self.active_state();
3390            if let Some(msg) = state.overlays.all().iter().find_map(|overlay| {
3391                let range = overlay.range(&state.marker_list);
3392                if range.start == new_pos && overlay.namespace.as_ref() == Some(&diagnostic_ns) {
3393                    overlay.message.clone()
3394                } else {
3395                    None
3396                }
3397            }) {
3398                self.set_status_message(msg);
3399            }
3400        }
3401    }
3402
3403    /// Jump to previous error/diagnostic
3404    pub(super) fn jump_to_previous_error(&mut self) {
3405        let diagnostic_ns = self.lsp_diagnostic_namespace.clone();
3406        let state = self.active_state_mut();
3407        let cursor_pos = state.cursors.primary().position;
3408        let cursor_id = state.cursors.primary_id();
3409        let cursor = *state.cursors.primary();
3410
3411        // Get all diagnostic overlay positions
3412        let mut diagnostic_positions: Vec<usize> = state
3413            .overlays
3414            .all()
3415            .iter()
3416            .filter_map(|overlay| {
3417                // Only consider LSP diagnostics (those in the diagnostic namespace)
3418                if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
3419                    Some(overlay.range(&state.marker_list).start)
3420                } else {
3421                    None
3422                }
3423            })
3424            .collect();
3425
3426        if diagnostic_positions.is_empty() {
3427            self.set_status_message(t!("diagnostics.none").to_string());
3428            return;
3429        }
3430
3431        // Sort positions
3432        diagnostic_positions.sort_unstable();
3433        diagnostic_positions.dedup();
3434
3435        // Find previous diagnostic before cursor position
3436        let prev_pos = diagnostic_positions
3437            .iter()
3438            .rev()
3439            .find(|&&pos| pos < cursor_pos)
3440            .or_else(|| diagnostic_positions.last()) // Wrap around
3441            .copied();
3442
3443        if let Some(new_pos) = prev_pos {
3444            let event = Event::MoveCursor {
3445                cursor_id,
3446                old_position: cursor.position,
3447                new_position: new_pos,
3448                old_anchor: cursor.anchor,
3449                new_anchor: None,
3450                old_sticky_column: cursor.sticky_column,
3451                new_sticky_column: 0,
3452            };
3453            self.active_event_log_mut().append(event.clone());
3454            self.apply_event_to_active_buffer(&event);
3455
3456            // Show diagnostic message in status bar
3457            let state = self.active_state();
3458            if let Some(msg) = state.overlays.all().iter().find_map(|overlay| {
3459                let range = overlay.range(&state.marker_list);
3460                if range.start == new_pos && overlay.namespace.as_ref() == Some(&diagnostic_ns) {
3461                    overlay.message.clone()
3462                } else {
3463                    None
3464                }
3465            }) {
3466                self.set_status_message(msg);
3467            }
3468        }
3469    }
3470
3471    /// Toggle macro recording for the given register
3472    pub(super) fn toggle_macro_recording(&mut self, key: char) {
3473        if let Some(state) = &self.macro_recording {
3474            if state.key == key {
3475                // Stop recording
3476                self.stop_macro_recording();
3477            } else {
3478                // Recording to a different key, stop current and start new
3479                self.stop_macro_recording();
3480                self.start_macro_recording(key);
3481            }
3482        } else {
3483            // Start recording
3484            self.start_macro_recording(key);
3485        }
3486    }
3487
3488    /// Start recording a macro
3489    pub(super) fn start_macro_recording(&mut self, key: char) {
3490        self.macro_recording = Some(MacroRecordingState {
3491            key,
3492            actions: Vec::new(),
3493        });
3494
3495        // Build the stop hint dynamically from keybindings
3496        let stop_hint = self.build_macro_stop_hint(key);
3497        self.set_status_message(
3498            t!(
3499                "macro.recording_with_hint",
3500                key = key,
3501                stop_hint = stop_hint
3502            )
3503            .to_string(),
3504        );
3505    }
3506
3507    /// Build a hint message for how to stop macro recording
3508    fn build_macro_stop_hint(&self, _key: char) -> String {
3509        let mut hints = Vec::new();
3510
3511        // Check for F5 (stop_macro_recording)
3512        if let Some(stop_key) = self.get_keybinding_for_action("stop_macro_recording") {
3513            hints.push(stop_key);
3514        }
3515
3516        // Get command palette keybinding
3517        let palette_key = self
3518            .get_keybinding_for_action("command_palette")
3519            .unwrap_or_else(|| "Ctrl+P".to_string());
3520
3521        if hints.is_empty() {
3522            // No keybindings found, just mention command palette
3523            format!("{} → Stop Recording Macro", palette_key)
3524        } else {
3525            // Show keybindings and command palette
3526            format!("{} or {} → Stop Recording", hints.join("/"), palette_key)
3527        }
3528    }
3529
3530    /// Stop recording and save the macro
3531    pub(super) fn stop_macro_recording(&mut self) {
3532        if let Some(state) = self.macro_recording.take() {
3533            let action_count = state.actions.len();
3534            let key = state.key;
3535            self.macros.insert(key, state.actions);
3536            self.last_macro_register = Some(key);
3537
3538            // Build play hint
3539            let play_hint = self.build_macro_play_hint();
3540            self.set_status_message(
3541                t!(
3542                    "macro.saved",
3543                    key = key,
3544                    count = action_count,
3545                    play_hint = play_hint
3546                )
3547                .to_string(),
3548            );
3549        } else {
3550            self.set_status_message(t!("macro.not_recording").to_string());
3551        }
3552    }
3553
3554    /// Build a hint message for how to play a macro
3555    fn build_macro_play_hint(&self) -> String {
3556        // Get command palette keybinding
3557        let palette_key = self
3558            .get_keybinding_for_action("command_palette")
3559            .unwrap_or_else(|| "Ctrl+P".to_string());
3560
3561        format!("{} → Play Macro", palette_key)
3562    }
3563
3564    /// Play back a recorded macro
3565    pub(super) fn play_macro(&mut self, key: char) {
3566        // Prevent recursive macro playback
3567        if self.macro_playing {
3568            return;
3569        }
3570
3571        if let Some(actions) = self.macros.get(&key).cloned() {
3572            if actions.is_empty() {
3573                self.set_status_message(t!("macro.empty", key = key).to_string());
3574                return;
3575            }
3576
3577            // Temporarily disable recording to avoid recording the playback
3578            let was_recording = self.macro_recording.take();
3579            self.macro_playing = true;
3580
3581            let action_count = actions.len();
3582            for action in actions {
3583                let _ = self.handle_action(action);
3584            }
3585
3586            // Restore recording and playing state
3587            self.macro_recording = was_recording;
3588            self.macro_playing = false;
3589
3590            self.set_status_message(
3591                t!("macro.played", key = key, count = action_count).to_string(),
3592            );
3593        } else {
3594            self.set_status_message(t!("macro.not_found", key = key).to_string());
3595        }
3596    }
3597
3598    /// Record an action to the current macro (if recording)
3599    pub(super) fn record_macro_action(&mut self, action: &Action) {
3600        if let Some(state) = &mut self.macro_recording {
3601            // Don't record macro control actions themselves
3602            match action {
3603                Action::StartMacroRecording
3604                | Action::StopMacroRecording
3605                | Action::PlayMacro(_)
3606                | Action::ToggleMacroRecording(_)
3607                | Action::ShowMacro(_)
3608                | Action::ListMacros
3609                | Action::PromptRecordMacro
3610                | Action::PromptPlayMacro
3611                | Action::PlayLastMacro => {}
3612                // When recording PromptConfirm, capture the current prompt text
3613                // so it can be replayed correctly
3614                Action::PromptConfirm => {
3615                    if let Some(prompt) = &self.prompt {
3616                        let text = prompt.get_text().to_string();
3617                        state.actions.push(Action::PromptConfirmWithText(text));
3618                    } else {
3619                        state.actions.push(action.clone());
3620                    }
3621                }
3622                _ => {
3623                    state.actions.push(action.clone());
3624                }
3625            }
3626        }
3627    }
3628
3629    /// Show a macro in a buffer as JSON
3630    pub(super) fn show_macro_in_buffer(&mut self, key: char) {
3631        // Get macro data and cache what we need before any mutable borrows
3632        let (json, actions_len) = match self.macros.get(&key) {
3633            Some(actions) => {
3634                let json = match serde_json::to_string_pretty(actions) {
3635                    Ok(json) => json,
3636                    Err(e) => {
3637                        self.set_status_message(
3638                            t!("macro.serialize_failed", error = e.to_string()).to_string(),
3639                        );
3640                        return;
3641                    }
3642                };
3643                (json, actions.len())
3644            }
3645            None => {
3646                self.set_status_message(t!("macro.not_found", key = key).to_string());
3647                return;
3648            }
3649        };
3650
3651        // Create header with macro info
3652        let content = format!(
3653            "// Macro '{}' ({} actions)\n// This buffer can be saved as a .json file for persistence\n\n{}",
3654            key,
3655            actions_len,
3656            json
3657        );
3658
3659        // Create a new buffer for the macro
3660        let buffer_id = BufferId(self.next_buffer_id);
3661        self.next_buffer_id += 1;
3662
3663        let mut state = EditorState::new(
3664            self.terminal_width,
3665            self.terminal_height,
3666            self.config.editor.large_file_threshold_bytes as usize,
3667            std::sync::Arc::clone(&self.filesystem),
3668        );
3669        state
3670            .margins
3671            .set_line_numbers(self.config.editor.line_numbers);
3672
3673        self.buffers.insert(buffer_id, state);
3674        self.event_logs.insert(buffer_id, EventLog::new());
3675
3676        // Set buffer content
3677        if let Some(state) = self.buffers.get_mut(&buffer_id) {
3678            state.buffer = crate::model::buffer::Buffer::from_str(
3679                &content,
3680                self.config.editor.large_file_threshold_bytes as usize,
3681                std::sync::Arc::clone(&self.filesystem),
3682            );
3683        }
3684
3685        // Set metadata
3686        let metadata = BufferMetadata {
3687            kind: BufferKind::Virtual {
3688                mode: "macro-view".to_string(),
3689            },
3690            display_name: format!("*Macro {}*", key),
3691            lsp_enabled: false,
3692            lsp_disabled_reason: Some("Virtual macro buffer".to_string()),
3693            read_only: false, // Allow editing for saving
3694            binary: false,
3695            lsp_opened_with: std::collections::HashSet::new(),
3696            hidden_from_tabs: false,
3697            recovery_id: None,
3698        };
3699        self.buffer_metadata.insert(buffer_id, metadata);
3700
3701        // Switch to the new buffer
3702        self.set_active_buffer(buffer_id);
3703        self.set_status_message(
3704            t!("macro.shown_buffer", key = key, count = actions_len).to_string(),
3705        );
3706    }
3707
3708    /// List all recorded macros in a buffer
3709    pub(super) fn list_macros_in_buffer(&mut self) {
3710        if self.macros.is_empty() {
3711            self.set_status_message(t!("macro.none_recorded").to_string());
3712            return;
3713        }
3714
3715        // Build a summary of all macros
3716        let mut content =
3717            String::from("// Recorded Macros\n// Use ShowMacro(key) to see details\n\n");
3718
3719        let mut keys: Vec<char> = self.macros.keys().copied().collect();
3720        keys.sort();
3721
3722        for key in keys {
3723            if let Some(actions) = self.macros.get(&key) {
3724                content.push_str(&format!("Macro '{}': {} actions\n", key, actions.len()));
3725
3726                // Show all actions
3727                for (i, action) in actions.iter().enumerate() {
3728                    content.push_str(&format!("  {}. {:?}\n", i + 1, action));
3729                }
3730                content.push('\n');
3731            }
3732        }
3733
3734        // Create a new buffer for the macro list
3735        let buffer_id = BufferId(self.next_buffer_id);
3736        self.next_buffer_id += 1;
3737
3738        let mut state = EditorState::new(
3739            self.terminal_width,
3740            self.terminal_height,
3741            self.config.editor.large_file_threshold_bytes as usize,
3742            std::sync::Arc::clone(&self.filesystem),
3743        );
3744        state
3745            .margins
3746            .set_line_numbers(self.config.editor.line_numbers);
3747
3748        self.buffers.insert(buffer_id, state);
3749        self.event_logs.insert(buffer_id, EventLog::new());
3750
3751        // Set buffer content
3752        if let Some(state) = self.buffers.get_mut(&buffer_id) {
3753            state.buffer = crate::model::buffer::Buffer::from_str(
3754                &content,
3755                self.config.editor.large_file_threshold_bytes as usize,
3756                std::sync::Arc::clone(&self.filesystem),
3757            );
3758        }
3759
3760        // Set metadata
3761        let metadata = BufferMetadata {
3762            kind: BufferKind::Virtual {
3763                mode: "macro-list".to_string(),
3764            },
3765            display_name: "*Macros*".to_string(),
3766            lsp_enabled: false,
3767            lsp_disabled_reason: Some("Virtual macro list buffer".to_string()),
3768            read_only: true,
3769            binary: false,
3770            lsp_opened_with: std::collections::HashSet::new(),
3771            hidden_from_tabs: false,
3772            recovery_id: None,
3773        };
3774        self.buffer_metadata.insert(buffer_id, metadata);
3775
3776        // Switch to the new buffer
3777        self.set_active_buffer(buffer_id);
3778        self.set_status_message(t!("macro.showing", count = self.macros.len()).to_string());
3779    }
3780
3781    /// Set a bookmark at the current position
3782    pub(super) fn set_bookmark(&mut self, key: char) {
3783        let buffer_id = self.active_buffer();
3784        let position = self.active_state().cursors.primary().position;
3785        self.bookmarks.insert(
3786            key,
3787            Bookmark {
3788                buffer_id,
3789                position,
3790            },
3791        );
3792        self.set_status_message(t!("bookmark.set", key = key).to_string());
3793    }
3794
3795    /// Jump to a bookmark
3796    pub(super) fn jump_to_bookmark(&mut self, key: char) {
3797        if let Some(bookmark) = self.bookmarks.get(&key).cloned() {
3798            // Switch to the buffer if needed
3799            if bookmark.buffer_id != self.active_buffer() {
3800                if self.buffers.contains_key(&bookmark.buffer_id) {
3801                    self.set_active_buffer(bookmark.buffer_id);
3802                } else {
3803                    self.set_status_message(t!("bookmark.buffer_gone", key = key).to_string());
3804                    self.bookmarks.remove(&key);
3805                    return;
3806                }
3807            }
3808
3809            // Move cursor to bookmark position
3810            let state = self.active_state_mut();
3811            let cursor_id = state.cursors.primary_id();
3812            let old_pos = state.cursors.primary().position;
3813            let new_pos = bookmark.position.min(state.buffer.len());
3814
3815            let event = Event::MoveCursor {
3816                cursor_id,
3817                old_position: old_pos,
3818                new_position: new_pos,
3819                old_anchor: state.cursors.primary().anchor,
3820                new_anchor: None,
3821                old_sticky_column: state.cursors.primary().sticky_column,
3822                new_sticky_column: 0,
3823            };
3824
3825            self.active_event_log_mut().append(event.clone());
3826            self.apply_event_to_active_buffer(&event);
3827            self.set_status_message(t!("bookmark.jumped", key = key).to_string());
3828        } else {
3829            self.set_status_message(t!("bookmark.not_set", key = key).to_string());
3830        }
3831    }
3832
3833    /// Clear a bookmark
3834    pub(super) fn clear_bookmark(&mut self, key: char) {
3835        if self.bookmarks.remove(&key).is_some() {
3836            self.set_status_message(t!("bookmark.cleared", key = key).to_string());
3837        } else {
3838            self.set_status_message(t!("bookmark.not_set", key = key).to_string());
3839        }
3840    }
3841
3842    /// List all bookmarks
3843    pub(super) fn list_bookmarks(&mut self) {
3844        if self.bookmarks.is_empty() {
3845            self.set_status_message(t!("bookmark.none_set").to_string());
3846            return;
3847        }
3848
3849        let mut bookmark_list: Vec<_> = self.bookmarks.iter().collect();
3850        bookmark_list.sort_by_key(|(k, _)| *k);
3851
3852        let list_str: String = bookmark_list
3853            .iter()
3854            .map(|(k, bm)| {
3855                let buffer_name = self
3856                    .buffer_metadata
3857                    .get(&bm.buffer_id)
3858                    .map(|m| m.display_name.as_str())
3859                    .unwrap_or("unknown");
3860                format!("'{}': {} @ {}", k, buffer_name, bm.position)
3861            })
3862            .collect::<Vec<_>>()
3863            .join(", ");
3864
3865        self.set_status_message(t!("bookmark.list", list = list_str).to_string());
3866    }
3867
3868    /// Clear the search history
3869    /// Used primarily for testing to ensure test isolation
3870    pub fn clear_search_history(&mut self) {
3871        if let Some(history) = self.prompt_histories.get_mut("search") {
3872            history.clear();
3873        }
3874    }
3875
3876    /// Save all prompt histories to disk
3877    /// Called on shutdown to persist history across sessions
3878    pub fn save_histories(&self) {
3879        // Ensure data directory exists
3880        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.data_dir) {
3881            tracing::warn!("Failed to create data directory: {}", e);
3882            return;
3883        }
3884
3885        // Save all prompt histories
3886        for (key, history) in &self.prompt_histories {
3887            let path = self.dir_context.prompt_history_path(key);
3888            if let Err(e) = history.save_to_file(&path) {
3889                tracing::warn!("Failed to save {} history: {}", key, e);
3890            } else {
3891                tracing::debug!("Saved {} history to {:?}", key, path);
3892            }
3893        }
3894    }
3895
3896    /// Ensure the active tab in a split is visible by adjusting its scroll offset.
3897    /// This function recalculates the required scroll_offset based on the active tab's position
3898    /// and the available width, and updates the SplitViewState.
3899    pub(super) fn ensure_active_tab_visible(
3900        &mut self,
3901        split_id: SplitId,
3902        active_buffer: BufferId,
3903        available_width: u16,
3904    ) {
3905        tracing::debug!(
3906            "ensure_active_tab_visible called: split={:?}, buffer={:?}, width={}",
3907            split_id,
3908            active_buffer,
3909            available_width
3910        );
3911        let Some(view_state) = self.split_view_states.get_mut(&split_id) else {
3912            tracing::debug!("  -> no view_state for split");
3913            return;
3914        };
3915
3916        let split_buffers = view_state.open_buffers.clone();
3917
3918        // Use the shared function to calculate tab widths (same as render_for_split)
3919        let (tab_widths, rendered_buffer_ids) = crate::view::ui::tabs::calculate_tab_widths(
3920            &split_buffers,
3921            &self.buffers,
3922            &self.buffer_metadata,
3923            &self.composite_buffers,
3924        );
3925
3926        let total_tabs_width: usize = tab_widths.iter().sum();
3927        let max_visible_width = available_width as usize;
3928
3929        // Find the active tab index among rendered buffers
3930        // Note: tab_widths includes separators, so we need to map buffer index to width index
3931        let active_tab_index = rendered_buffer_ids
3932            .iter()
3933            .position(|id| *id == active_buffer);
3934
3935        // Map buffer index to width index (accounting for separators)
3936        // Widths are: [sep?, tab0, sep, tab1, sep, tab2, ...]
3937        // First tab has no separator before it, subsequent tabs have separator before
3938        let active_width_index = active_tab_index.map(|buf_idx| {
3939            if buf_idx == 0 {
3940                0
3941            } else {
3942                // Each tab after the first has a separator before it
3943                // So tab N is at position 2*N (sep before tab1 is at 1, tab1 at 2, sep before tab2 at 3, tab2 at 4, etc.)
3944                // Wait, the structure is: [tab0, sep, tab1, sep, tab2]
3945                // So tab N (0-indexed) is at position 2*N
3946                buf_idx * 2
3947            }
3948        });
3949
3950        // Calculate offset to bring active tab into view
3951        let old_offset = view_state.tab_scroll_offset;
3952        let new_scroll_offset = if let Some(idx) = active_width_index {
3953            crate::view::ui::tabs::scroll_to_show_tab(
3954                &tab_widths,
3955                idx,
3956                view_state.tab_scroll_offset,
3957                max_visible_width,
3958            )
3959        } else {
3960            view_state
3961                .tab_scroll_offset
3962                .min(total_tabs_width.saturating_sub(max_visible_width))
3963        };
3964
3965        tracing::debug!(
3966            "  -> offset: {} -> {} (idx={:?}, max_width={}, total={})",
3967            old_offset,
3968            new_scroll_offset,
3969            active_width_index,
3970            max_visible_width,
3971            total_tabs_width
3972        );
3973        view_state.tab_scroll_offset = new_scroll_offset;
3974    }
3975
3976    /// Synchronize viewports for all scroll sync groups
3977    ///
3978    /// This syncs the inactive split's viewport to match the active split's position.
3979    /// By deriving from the active split's actual viewport, we capture all viewport
3980    /// changes regardless of source (scroll events, cursor movements, etc.).
3981    fn sync_scroll_groups(&mut self) {
3982        let active_split = self.split_manager.active_split();
3983        let group_count = self.scroll_sync_manager.groups().len();
3984
3985        if group_count > 0 {
3986            tracing::debug!(
3987                "sync_scroll_groups: active_split={:?}, {} groups",
3988                active_split,
3989                group_count
3990            );
3991        }
3992
3993        // Collect sync info: for each group where active split participates,
3994        // get the active split's current line position
3995        let sync_info: Vec<_> = self
3996            .scroll_sync_manager
3997            .groups()
3998            .iter()
3999            .filter_map(|group| {
4000                tracing::debug!(
4001                    "sync_scroll_groups: checking group {}, left={:?}, right={:?}",
4002                    group.id,
4003                    group.left_split,
4004                    group.right_split
4005                );
4006
4007                if !group.contains_split(active_split) {
4008                    tracing::debug!(
4009                        "sync_scroll_groups: active split {:?} not in group",
4010                        active_split
4011                    );
4012                    return None;
4013                }
4014
4015                // Get active split's current viewport top_byte
4016                let active_top_byte = self
4017                    .split_view_states
4018                    .get(&active_split)?
4019                    .viewport
4020                    .top_byte;
4021
4022                // Get active split's buffer to convert bytes → line
4023                let active_buffer_id = self.split_manager.buffer_for_split(active_split)?;
4024                let buffer_state = self.buffers.get(&active_buffer_id)?;
4025                let buffer_len = buffer_state.buffer.len();
4026                let active_line = buffer_state.buffer.get_line_number(active_top_byte);
4027
4028                tracing::debug!(
4029                    "sync_scroll_groups: active_split={:?}, buffer_id={:?}, top_byte={}, buffer_len={}, active_line={}",
4030                    active_split,
4031                    active_buffer_id,
4032                    active_top_byte,
4033                    buffer_len,
4034                    active_line
4035                );
4036
4037                // Determine the other split and compute its target line
4038                let (other_split, other_line) = if group.is_left_split(active_split) {
4039                    // Active is left, sync right
4040                    (group.right_split, group.left_to_right_line(active_line))
4041                } else {
4042                    // Active is right, sync left
4043                    (group.left_split, group.right_to_left_line(active_line))
4044                };
4045
4046                tracing::debug!(
4047                    "sync_scroll_groups: syncing other_split={:?} to line {}",
4048                    other_split,
4049                    other_line
4050                );
4051
4052                Some((other_split, other_line))
4053            })
4054            .collect();
4055
4056        // Apply sync to other splits
4057        for (other_split, target_line) in sync_info {
4058            if let Some(buffer_id) = self.split_manager.buffer_for_split(other_split) {
4059                if let Some(state) = self.buffers.get_mut(&buffer_id) {
4060                    let buffer = &mut state.buffer;
4061                    if let Some(view_state) = self.split_view_states.get_mut(&other_split) {
4062                        view_state.viewport.scroll_to(buffer, target_line);
4063                    }
4064                }
4065            }
4066        }
4067    }
4068
4069    /// Pre-sync ensure_visible for scroll sync groups
4070    ///
4071    /// When the active split is in a scroll sync group, we need to update its viewport
4072    /// BEFORE sync_scroll_groups runs. This ensures cursor movements like 'G' (go to end)
4073    /// properly sync to the other split.
4074    ///
4075    /// After updating the active split's viewport, we mark the OTHER splits in the group
4076    /// to skip ensure_visible so the sync position isn't undone during rendering.
4077    fn pre_sync_ensure_visible(&mut self, active_split: SplitId) {
4078        // Check if active split is in any scroll sync group
4079        let group_info = self
4080            .scroll_sync_manager
4081            .find_group_for_split(active_split)
4082            .map(|g| (g.left_split, g.right_split));
4083
4084        let Some((left_split, right_split)) = group_info else {
4085            return;
4086        };
4087
4088        // Get the active split's buffer and update its viewport
4089        if let Some(buffer_id) = self.split_manager.buffer_for_split(active_split) {
4090            if let Some(state) = self.buffers.get_mut(&buffer_id) {
4091                let buffer = &mut state.buffer;
4092                let cursor = *state.cursors.primary();
4093
4094                if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
4095                    // Update viewport to show cursor - this is what ensure_visible does
4096                    view_state.viewport.ensure_visible(buffer, &cursor);
4097
4098                    tracing::debug!(
4099                        "pre_sync_ensure_visible: updated active split {:?} viewport, top_byte={}",
4100                        active_split,
4101                        view_state.viewport.top_byte
4102                    );
4103                }
4104            }
4105        }
4106
4107        // Mark the OTHER split to skip ensure_visible so the sync position isn't undone
4108        let other_split = if active_split == left_split {
4109            right_split
4110        } else {
4111            left_split
4112        };
4113
4114        if let Some(view_state) = self.split_view_states.get_mut(&other_split) {
4115            view_state.viewport.set_skip_ensure_visible();
4116            tracing::debug!(
4117                "pre_sync_ensure_visible: marked other split {:?} to skip ensure_visible",
4118                other_split
4119            );
4120        }
4121    }
4122}