Skip to main content

fresh/app/
render.rs

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