Skip to main content

fresh/app/
render.rs

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