Skip to main content

fresh/app/
render.rs

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