Skip to main content

fresh/app/
render.rs

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