Skip to main content

fresh/app/
input.rs

1use super::*;
2use crate::model::event::LeafId;
3use crate::services::plugins::hooks::HookArgs;
4use anyhow::Result as AnyhowResult;
5use rust_i18n::t;
6impl Editor {
7    /// Determine the current keybinding context based on UI state
8    pub fn get_key_context(&self) -> crate::input::keybindings::KeyContext {
9        use crate::input::keybindings::KeyContext;
10
11        // Priority order: Settings > Menu > Prompt > Popup > Rename > Current context (FileExplorer or Normal)
12        if self.settings_state.as_ref().is_some_and(|s| s.visible) {
13            KeyContext::Settings
14        } else if self.menu_state.active_menu.is_some() {
15            KeyContext::Menu
16        } else if self.is_prompting() {
17            KeyContext::Prompt
18        } else if self.active_state().popups.is_visible() {
19            KeyContext::Popup
20        } else {
21            // Use the current context (can be FileExplorer or Normal)
22            self.key_context.clone()
23        }
24    }
25
26    /// Handle a key event and return whether it was handled
27    /// This is the central key handling logic used by both main.rs and tests
28    pub fn handle_key(
29        &mut self,
30        code: crossterm::event::KeyCode,
31        modifiers: crossterm::event::KeyModifiers,
32    ) -> AnyhowResult<()> {
33        use crate::input::keybindings::Action;
34
35        let _t_total = std::time::Instant::now();
36
37        tracing::trace!(
38            "Editor.handle_key: code={:?}, modifiers={:?}",
39            code,
40            modifiers
41        );
42
43        // Create key event for dispatch methods
44        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
45
46        // Event debug dialog intercepts ALL key events before any other processing.
47        // This must be checked here (not just in main.rs/gui) so it works in
48        // client/server mode where handle_key is called directly.
49        if self.is_event_debug_active() {
50            self.handle_event_debug_input(&key_event);
51            return Ok(());
52        }
53
54        // Try terminal input dispatch first (handles terminal mode and re-entry)
55        if self.dispatch_terminal_input(&key_event).is_some() {
56            return Ok(());
57        }
58
59        // Clear skip_ensure_visible flag so cursor becomes visible after key press
60        // (scroll actions will set it again if needed)
61        let active_split = self.split_manager.active_split();
62        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
63            view_state.viewport.clear_skip_ensure_visible();
64        }
65
66        // Dismiss theme info popup on any key press
67        if self.theme_info_popup.is_some() {
68            self.theme_info_popup = None;
69        }
70
71        // Determine the current context first
72        let mut context = self.get_key_context();
73
74        // Special case: Hover and Signature Help popups should be dismissed on any key press
75        // EXCEPT for Ctrl+C when the popup has a text selection (allow copy first)
76        if matches!(context, crate::input::keybindings::KeyContext::Popup) {
77            // Check if the current popup is transient (hover, signature help)
78            let (is_transient_popup, has_selection) = {
79                let popup = self.active_state().popups.top();
80                (
81                    popup.is_some_and(|p| p.transient),
82                    popup.is_some_and(|p| p.has_selection()),
83                )
84            };
85
86            // Don't dismiss if popup has selection and user is pressing Ctrl+C (let them copy first)
87            let is_copy_key = key_event.code == crossterm::event::KeyCode::Char('c')
88                && key_event
89                    .modifiers
90                    .contains(crossterm::event::KeyModifiers::CONTROL);
91
92            if is_transient_popup && !(has_selection && is_copy_key) {
93                // Dismiss the popup on any key press (except Ctrl+C with selection)
94                self.hide_popup();
95                tracing::debug!("Dismissed transient popup on key press");
96                // Recalculate context now that popup is gone
97                context = self.get_key_context();
98            }
99        }
100
101        // Try hierarchical modal input dispatch first (Settings, Menu, Prompt, Popup)
102        if self.dispatch_modal_input(&key_event).is_some() {
103            return Ok(());
104        }
105
106        // If a modal was dismissed (e.g., completion popup closed and returned Ignored),
107        // recalculate the context so the key is processed in the correct context.
108        if context != self.get_key_context() {
109            context = self.get_key_context();
110        }
111
112        // Only check buffer mode keybindings when the editor buffer has focus.
113        // FileExplorer, Menu, Prompt, Popup contexts should not trigger mode bindings
114        // (e.g. markdown-source's Enter handler should not fire while the explorer is focused).
115        let should_check_mode_bindings =
116            matches!(context, crate::input::keybindings::KeyContext::Normal);
117
118        if should_check_mode_bindings {
119            // effective_mode() returns buffer-local mode if present, else global mode.
120            // This ensures virtual buffer modes aren't hijacked by global modes.
121            let effective_mode = self.effective_mode().map(|s| s.to_owned());
122
123            if let Some(ref mode_name) = effective_mode {
124                let mode_ctx = crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
125                let key_event = crossterm::event::KeyEvent::new(code, modifiers);
126
127                // Mode chord resolution (via KeybindingResolver)
128                let chord_result =
129                    self.keybindings
130                        .resolve_chord(&self.chord_state, &key_event, mode_ctx.clone());
131                match chord_result {
132                    crate::input::keybindings::ChordResolution::Complete(action) => {
133                        tracing::debug!("Mode chord resolved to action: {:?}", action);
134                        self.chord_state.clear();
135                        return self.handle_action(action);
136                    }
137                    crate::input::keybindings::ChordResolution::Partial => {
138                        tracing::debug!("Potential chord prefix in mode '{}'", mode_name);
139                        self.chord_state.push((code, modifiers));
140                        return Ok(());
141                    }
142                    crate::input::keybindings::ChordResolution::NoMatch => {
143                        if !self.chord_state.is_empty() {
144                            tracing::debug!("Chord sequence abandoned in mode, clearing state");
145                            self.chord_state.clear();
146                        }
147                    }
148                }
149
150                // Mode single-key resolution (custom > keymap > plugin defaults)
151                let resolved = self.keybindings.resolve(&key_event, mode_ctx);
152                if resolved != Action::None {
153                    return self.handle_action(resolved);
154                }
155            }
156
157            // Handle unbound keys for modes that want to capture input.
158            //
159            // Buffer-local modes with allow_text_input (e.g. search-replace-list)
160            // capture character keys and block other unbound keys.
161            //
162            // Buffer-local modes WITHOUT allow_text_input (e.g. diff-view) let
163            // unbound keys fall through to normal keybinding handling so that
164            // Ctrl+C, arrows, etc. still work.
165            //
166            // Global editor modes (e.g. vi-normal) block all unbound keys when
167            // read-only.
168            if let Some(ref mode_name) = effective_mode {
169                if self.mode_registry.allows_text_input(mode_name) {
170                    if let KeyCode::Char(c) = code {
171                        let ch = if modifiers.contains(KeyModifiers::SHIFT) {
172                            c.to_uppercase().next().unwrap_or(c)
173                        } else {
174                            c
175                        };
176                        if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
177                            let action_name = format!("mode_text_input:{}", ch);
178                            return self.handle_action(Action::PluginAction(action_name));
179                        }
180                    }
181                    tracing::debug!("Blocking unbound key in text-input mode '{}'", mode_name);
182                    return Ok(());
183                }
184            }
185            if let Some(ref mode_name) = self.editor_mode {
186                if self.mode_registry.is_read_only(mode_name) {
187                    tracing::debug!("Ignoring unbound key in read-only mode '{}'", mode_name);
188                    return Ok(());
189                }
190                tracing::debug!(
191                    "Mode '{}' is not read-only, allowing key through",
192                    mode_name
193                );
194            }
195        }
196
197        // Check for chord sequence matches first
198        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
199        let chord_result =
200            self.keybindings
201                .resolve_chord(&self.chord_state, &key_event, context.clone());
202
203        match chord_result {
204            crate::input::keybindings::ChordResolution::Complete(action) => {
205                // Complete chord match - execute action and clear chord state
206                tracing::debug!("Complete chord match -> Action: {:?}", action);
207                self.chord_state.clear();
208                return self.handle_action(action);
209            }
210            crate::input::keybindings::ChordResolution::Partial => {
211                // Partial match - add to chord state and wait for more keys
212                tracing::debug!("Partial chord match - waiting for next key");
213                self.chord_state.push((code, modifiers));
214                return Ok(());
215            }
216            crate::input::keybindings::ChordResolution::NoMatch => {
217                // No chord match - clear state and try regular resolution
218                if !self.chord_state.is_empty() {
219                    tracing::debug!("Chord sequence abandoned, clearing state");
220                    self.chord_state.clear();
221                }
222            }
223        }
224
225        // Regular single-key resolution
226        let action = self.keybindings.resolve(&key_event, context.clone());
227
228        tracing::trace!("Context: {:?} -> Action: {:?}", context, action);
229
230        // Cancel pending LSP requests on user actions (except LSP actions themselves)
231        // This ensures stale completions don't show up after the user has moved on
232        match action {
233            Action::LspCompletion
234            | Action::LspGotoDefinition
235            | Action::LspReferences
236            | Action::LspHover
237            | Action::None => {
238                // Don't cancel for LSP actions or no-op
239            }
240            _ => {
241                // Cancel any pending LSP requests
242                self.cancel_pending_lsp_requests();
243            }
244        }
245
246        // Note: Modal components (Settings, Menu, Prompt, Popup, File Browser) are now
247        // handled by dispatch_modal_input using the InputHandler system.
248        // All remaining actions delegate to handle_action.
249        self.handle_action(action)
250    }
251
252    /// Handle an action (for normal mode and command execution).
253    /// Used by the app module internally and by the GUI module for native menu dispatch.
254    pub(crate) fn handle_action(&mut self, action: Action) -> AnyhowResult<()> {
255        use crate::input::keybindings::Action;
256
257        // Record action to macro if recording
258        self.record_macro_action(&action);
259
260        // Reset dabbrev cycling session on any non-dabbrev action.
261        if !matches!(action, Action::DabbrevExpand) {
262            self.reset_dabbrev_state();
263        }
264
265        match action {
266            Action::Quit => self.quit(),
267            Action::ForceQuit => {
268                self.should_quit = true;
269            }
270            Action::Detach => {
271                self.should_detach = true;
272            }
273            Action::Save => {
274                // Check if buffer has a file path - if not, redirect to SaveAs
275                if self.active_state().buffer.file_path().is_none() {
276                    self.start_prompt_with_initial_text(
277                        t!("file.save_as_prompt").to_string(),
278                        PromptType::SaveFileAs,
279                        String::new(),
280                    );
281                    self.init_file_open_state();
282                } else if self.check_save_conflict().is_some() {
283                    // Check if file was modified externally since we opened/saved it
284                    self.start_prompt(
285                        t!("file.file_changed_prompt").to_string(),
286                        PromptType::ConfirmSaveConflict,
287                    );
288                } else {
289                    self.save()?;
290                }
291            }
292            Action::SaveAs => {
293                // Get current filename as default suggestion
294                let current_path = self
295                    .active_state()
296                    .buffer
297                    .file_path()
298                    .map(|p| {
299                        // Make path relative to working_dir if possible
300                        p.strip_prefix(&self.working_dir)
301                            .unwrap_or(p)
302                            .to_string_lossy()
303                            .to_string()
304                    })
305                    .unwrap_or_default();
306                self.start_prompt_with_initial_text(
307                    t!("file.save_as_prompt").to_string(),
308                    PromptType::SaveFileAs,
309                    current_path,
310                );
311                self.init_file_open_state();
312            }
313            Action::Open => {
314                self.start_prompt(t!("file.open_prompt").to_string(), PromptType::OpenFile);
315                self.prefill_open_file_prompt();
316                self.init_file_open_state();
317            }
318            Action::SwitchProject => {
319                self.start_prompt(
320                    t!("file.switch_project_prompt").to_string(),
321                    PromptType::SwitchProject,
322                );
323                self.init_folder_open_state();
324            }
325            Action::GotoLine => {
326                let has_line_index = self
327                    .buffers
328                    .get(&self.active_buffer())
329                    .is_none_or(|s| s.buffer.line_count().is_some());
330                if has_line_index {
331                    self.start_prompt(
332                        t!("file.goto_line_prompt").to_string(),
333                        PromptType::GotoLine,
334                    );
335                } else {
336                    self.start_prompt(
337                        t!("goto.scan_confirm_prompt", yes = "y", no = "N").to_string(),
338                        PromptType::GotoLineScanConfirm,
339                    );
340                }
341            }
342            Action::ScanLineIndex => {
343                self.start_incremental_line_scan(false);
344            }
345            Action::New => {
346                self.new_buffer();
347            }
348            Action::Close | Action::CloseTab => {
349                // Both Close and CloseTab use close_tab() which handles:
350                // - Closing the split if this is the last buffer and there are other splits
351                // - Prompting for unsaved changes
352                // - Properly closing the buffer
353                self.close_tab();
354            }
355            Action::Revert => {
356                // Check if buffer has unsaved changes - prompt for confirmation
357                if self.active_state().buffer.is_modified() {
358                    let revert_key = t!("prompt.key.revert").to_string();
359                    let cancel_key = t!("prompt.key.cancel").to_string();
360                    self.start_prompt(
361                        t!(
362                            "prompt.revert_confirm",
363                            revert_key = revert_key,
364                            cancel_key = cancel_key
365                        )
366                        .to_string(),
367                        PromptType::ConfirmRevert,
368                    );
369                } else {
370                    // No local changes, just revert
371                    if let Err(e) = self.revert_file() {
372                        self.set_status_message(
373                            t!("error.failed_to_revert", error = e.to_string()).to_string(),
374                        );
375                    }
376                }
377            }
378            Action::ToggleAutoRevert => {
379                self.toggle_auto_revert();
380            }
381            Action::FormatBuffer => {
382                if let Err(e) = self.format_buffer() {
383                    self.set_status_message(
384                        t!("error.format_failed", error = e.to_string()).to_string(),
385                    );
386                }
387            }
388            Action::TrimTrailingWhitespace => match self.trim_trailing_whitespace() {
389                Ok(true) => {
390                    self.set_status_message(t!("whitespace.trimmed").to_string());
391                }
392                Ok(false) => {
393                    self.set_status_message(t!("whitespace.no_trailing").to_string());
394                }
395                Err(e) => {
396                    self.set_status_message(
397                        t!("error.trim_whitespace_failed", error = e).to_string(),
398                    );
399                }
400            },
401            Action::EnsureFinalNewline => match self.ensure_final_newline() {
402                Ok(true) => {
403                    self.set_status_message(t!("whitespace.newline_added").to_string());
404                }
405                Ok(false) => {
406                    self.set_status_message(t!("whitespace.already_has_newline").to_string());
407                }
408                Err(e) => {
409                    self.set_status_message(
410                        t!("error.ensure_newline_failed", error = e).to_string(),
411                    );
412                }
413            },
414            Action::Copy => {
415                // Check if there's an active popup with text selection
416                let state = self.active_state();
417                if let Some(popup) = state.popups.top() {
418                    if popup.has_selection() {
419                        if let Some(text) = popup.get_selected_text() {
420                            self.clipboard.copy(text);
421                            self.set_status_message(t!("clipboard.copied").to_string());
422                            return Ok(());
423                        }
424                    }
425                }
426                // Check if active buffer is a composite buffer
427                let buffer_id = self.active_buffer();
428                if self.is_composite_buffer(buffer_id) {
429                    if let Some(_handled) = self.handle_composite_action(buffer_id, &Action::Copy) {
430                        return Ok(());
431                    }
432                }
433                self.copy_selection()
434            }
435            Action::CopyWithTheme(theme) => self.copy_selection_with_theme(&theme),
436            Action::Cut => {
437                if self.is_editing_disabled() {
438                    self.set_status_message(t!("buffer.editing_disabled").to_string());
439                    return Ok(());
440                }
441                self.cut_selection()
442            }
443            Action::Paste => {
444                if self.is_editing_disabled() {
445                    self.set_status_message(t!("buffer.editing_disabled").to_string());
446                    return Ok(());
447                }
448                self.paste()
449            }
450            Action::YankWordForward => self.yank_word_forward(),
451            Action::YankWordBackward => self.yank_word_backward(),
452            Action::YankToLineEnd => self.yank_to_line_end(),
453            Action::YankToLineStart => self.yank_to_line_start(),
454            Action::YankViWordEnd => self.yank_vi_word_end(),
455            Action::Undo => {
456                self.handle_undo();
457            }
458            Action::Redo => {
459                self.handle_redo();
460            }
461            Action::ShowHelp => {
462                self.open_help_manual();
463            }
464            Action::ShowKeyboardShortcuts => {
465                self.open_keyboard_shortcuts();
466            }
467            Action::ShowWarnings => {
468                self.show_warnings_popup();
469            }
470            Action::ShowStatusLog => {
471                self.open_status_log();
472            }
473            Action::ShowLspStatus => {
474                self.show_lsp_status_popup();
475            }
476            Action::ClearWarnings => {
477                self.clear_warnings();
478            }
479            Action::CommandPalette => {
480                // Toggle command palette: close if already open, otherwise open it
481                if let Some(prompt) = &self.prompt {
482                    if prompt.prompt_type == PromptType::Command {
483                        self.cancel_prompt();
484                        return Ok(());
485                    }
486                }
487
488                // Use the current context for filtering commands
489                let active_buffer_mode = self
490                    .buffer_metadata
491                    .get(&self.active_buffer())
492                    .and_then(|m| m.virtual_mode());
493                let has_lsp_config = {
494                    let language = self
495                        .buffers
496                        .get(&self.active_buffer())
497                        .map(|s| s.language.as_str());
498                    language
499                        .and_then(|lang| self.lsp.as_ref().and_then(|lsp| lsp.get_config(lang)))
500                        .is_some()
501                };
502                let suggestions = self.command_registry.read().unwrap().filter(
503                    "",
504                    self.key_context.clone(),
505                    &self.keybindings,
506                    self.has_active_selection(),
507                    &self.active_custom_contexts,
508                    active_buffer_mode,
509                    has_lsp_config,
510                );
511                self.start_prompt_with_suggestions(
512                    t!("file.command_prompt").to_string(),
513                    PromptType::Command,
514                    suggestions,
515                );
516            }
517            Action::QuickOpen => {
518                // Toggle Quick Open: close if already open, otherwise open it
519                if let Some(prompt) = &self.prompt {
520                    if prompt.prompt_type == PromptType::QuickOpen {
521                        self.cancel_prompt();
522                        return Ok(());
523                    }
524                }
525
526                // Start Quick Open with file suggestions (default mode)
527                self.start_quick_open();
528            }
529            Action::ToggleLineWrap => {
530                self.config.editor.line_wrap = !self.config.editor.line_wrap;
531
532                // Update all viewports to reflect the new line wrap setting,
533                // respecting per-language overrides
534                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
535                for leaf_id in leaf_ids {
536                    let buffer_id = self
537                        .split_manager
538                        .get_buffer_id(leaf_id.into())
539                        .unwrap_or(BufferId(0));
540                    let effective_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
541                    let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
542                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
543                        view_state.viewport.line_wrap_enabled = effective_wrap;
544                        view_state.viewport.wrap_indent = self.config.editor.wrap_indent;
545                        view_state.viewport.wrap_column = wrap_column;
546                    }
547                }
548
549                let state = if self.config.editor.line_wrap {
550                    t!("view.state_enabled").to_string()
551                } else {
552                    t!("view.state_disabled").to_string()
553                };
554                self.set_status_message(t!("view.line_wrap_state", state = state).to_string());
555            }
556            Action::ToggleCurrentLineHighlight => {
557                self.config.editor.highlight_current_line =
558                    !self.config.editor.highlight_current_line;
559
560                // Update all splits
561                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
562                for leaf_id in leaf_ids {
563                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
564                        view_state.highlight_current_line =
565                            self.config.editor.highlight_current_line;
566                    }
567                }
568
569                let state = if self.config.editor.highlight_current_line {
570                    t!("view.state_enabled").to_string()
571                } else {
572                    t!("view.state_disabled").to_string()
573                };
574                self.set_status_message(
575                    t!("view.current_line_highlight_state", state = state).to_string(),
576                );
577            }
578            Action::ToggleReadOnly => {
579                let buffer_id = self.active_buffer();
580                let is_now_read_only = self
581                    .buffer_metadata
582                    .get(&buffer_id)
583                    .map(|m| !m.read_only)
584                    .unwrap_or(false);
585                self.mark_buffer_read_only(buffer_id, is_now_read_only);
586
587                let state_str = if is_now_read_only {
588                    t!("view.state_enabled").to_string()
589                } else {
590                    t!("view.state_disabled").to_string()
591                };
592                self.set_status_message(t!("view.read_only_state", state = state_str).to_string());
593            }
594            Action::TogglePageView => {
595                self.handle_toggle_page_view();
596            }
597            Action::SetPageWidth => {
598                let active_split = self.split_manager.active_split();
599                let current = self
600                    .split_view_states
601                    .get(&active_split)
602                    .and_then(|v| v.compose_width.map(|w| w.to_string()))
603                    .unwrap_or_default();
604                self.start_prompt_with_initial_text(
605                    "Page width (empty = viewport): ".to_string(),
606                    PromptType::SetPageWidth,
607                    current,
608                );
609            }
610            Action::SetBackground => {
611                let default_path = self
612                    .ansi_background_path
613                    .as_ref()
614                    .and_then(|p| {
615                        p.strip_prefix(&self.working_dir)
616                            .ok()
617                            .map(|rel| rel.to_string_lossy().to_string())
618                    })
619                    .unwrap_or_else(|| DEFAULT_BACKGROUND_FILE.to_string());
620
621                self.start_prompt_with_initial_text(
622                    "Background file: ".to_string(),
623                    PromptType::SetBackgroundFile,
624                    default_path,
625                );
626            }
627            Action::SetBackgroundBlend => {
628                let default_amount = format!("{:.2}", self.background_fade);
629                self.start_prompt_with_initial_text(
630                    "Background blend (0-1): ".to_string(),
631                    PromptType::SetBackgroundBlend,
632                    default_amount,
633                );
634            }
635            Action::LspCompletion => {
636                self.request_completion();
637            }
638            Action::DabbrevExpand => {
639                self.dabbrev_expand();
640            }
641            Action::LspGotoDefinition => {
642                self.request_goto_definition()?;
643            }
644            Action::LspRename => {
645                self.start_rename()?;
646            }
647            Action::LspHover => {
648                self.request_hover()?;
649            }
650            Action::LspReferences => {
651                self.request_references()?;
652            }
653            Action::LspSignatureHelp => {
654                self.request_signature_help();
655            }
656            Action::LspCodeActions => {
657                self.request_code_actions()?;
658            }
659            Action::LspRestart => {
660                self.handle_lsp_restart();
661            }
662            Action::LspStop => {
663                self.handle_lsp_stop();
664            }
665            Action::LspToggleForBuffer => {
666                self.handle_lsp_toggle_for_buffer();
667            }
668            Action::ToggleInlayHints => {
669                self.toggle_inlay_hints();
670            }
671            Action::DumpConfig => {
672                self.dump_config();
673            }
674            Action::SelectTheme => {
675                self.start_select_theme_prompt();
676            }
677            Action::InspectThemeAtCursor => {
678                self.inspect_theme_at_cursor();
679            }
680            Action::SelectKeybindingMap => {
681                self.start_select_keybinding_map_prompt();
682            }
683            Action::SelectCursorStyle => {
684                self.start_select_cursor_style_prompt();
685            }
686            Action::SelectLocale => {
687                self.start_select_locale_prompt();
688            }
689            Action::Search => {
690                // If already in a search-related prompt, Ctrl+F acts like Enter (confirm search)
691                let is_search_prompt = self.prompt.as_ref().is_some_and(|p| {
692                    matches!(
693                        p.prompt_type,
694                        PromptType::Search
695                            | PromptType::ReplaceSearch
696                            | PromptType::QueryReplaceSearch
697                    )
698                });
699
700                if is_search_prompt {
701                    self.confirm_prompt();
702                } else {
703                    self.start_search_prompt(
704                        t!("file.search_prompt").to_string(),
705                        PromptType::Search,
706                        false,
707                    );
708                }
709            }
710            Action::Replace => {
711                // Use same flow as query-replace, just with confirm_each defaulting to false
712                self.start_search_prompt(
713                    t!("file.replace_prompt").to_string(),
714                    PromptType::ReplaceSearch,
715                    false,
716                );
717            }
718            Action::QueryReplace => {
719                // Enable confirm mode by default for query-replace
720                self.search_confirm_each = true;
721                self.start_search_prompt(
722                    "Query replace: ".to_string(),
723                    PromptType::QueryReplaceSearch,
724                    false,
725                );
726            }
727            Action::FindInSelection => {
728                self.start_search_prompt(
729                    t!("file.search_prompt").to_string(),
730                    PromptType::Search,
731                    true,
732                );
733            }
734            Action::FindNext => {
735                self.find_next();
736            }
737            Action::FindPrevious => {
738                self.find_previous();
739            }
740            Action::FindSelectionNext => {
741                self.find_selection_next();
742            }
743            Action::FindSelectionPrevious => {
744                self.find_selection_previous();
745            }
746            Action::AddCursorNextMatch => self.add_cursor_at_next_match(),
747            Action::AddCursorAbove => self.add_cursor_above(),
748            Action::AddCursorBelow => self.add_cursor_below(),
749            Action::NextBuffer => self.next_buffer(),
750            Action::PrevBuffer => self.prev_buffer(),
751            Action::SwitchToPreviousTab => self.switch_to_previous_tab(),
752            Action::SwitchToTabByName => self.start_switch_to_tab_prompt(),
753
754            // Tab scrolling (manual scroll - don't auto-adjust)
755            Action::ScrollTabsLeft => {
756                let active_split_id = self.split_manager.active_split();
757                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
758                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_sub(5);
759                    self.set_status_message(t!("status.scrolled_tabs_left").to_string());
760                }
761            }
762            Action::ScrollTabsRight => {
763                let active_split_id = self.split_manager.active_split();
764                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
765                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_add(5);
766                    self.set_status_message(t!("status.scrolled_tabs_right").to_string());
767                }
768            }
769            Action::NavigateBack => self.navigate_back(),
770            Action::NavigateForward => self.navigate_forward(),
771            Action::SplitHorizontal => self.split_pane_horizontal(),
772            Action::SplitVertical => self.split_pane_vertical(),
773            Action::CloseSplit => self.close_active_split(),
774            Action::NextSplit => self.next_split(),
775            Action::PrevSplit => self.prev_split(),
776            Action::IncreaseSplitSize => self.adjust_split_size(0.05),
777            Action::DecreaseSplitSize => self.adjust_split_size(-0.05),
778            Action::ToggleMaximizeSplit => self.toggle_maximize_split(),
779            Action::ToggleFileExplorer => self.toggle_file_explorer(),
780            Action::ToggleMenuBar => self.toggle_menu_bar(),
781            Action::ToggleTabBar => self.toggle_tab_bar(),
782            Action::ToggleStatusBar => self.toggle_status_bar(),
783            Action::TogglePromptLine => self.toggle_prompt_line(),
784            Action::ToggleVerticalScrollbar => self.toggle_vertical_scrollbar(),
785            Action::ToggleHorizontalScrollbar => self.toggle_horizontal_scrollbar(),
786            Action::ToggleLineNumbers => self.toggle_line_numbers(),
787            Action::ToggleScrollSync => self.toggle_scroll_sync(),
788            Action::ToggleMouseCapture => self.toggle_mouse_capture(),
789            Action::ToggleMouseHover => self.toggle_mouse_hover(),
790            Action::ToggleDebugHighlights => self.toggle_debug_highlights(),
791            // Rulers
792            Action::AddRuler => {
793                self.start_prompt(t!("rulers.add_prompt").to_string(), PromptType::AddRuler);
794            }
795            Action::RemoveRuler => {
796                self.start_remove_ruler_prompt();
797            }
798            // Buffer settings
799            Action::SetTabSize => {
800                let current = self
801                    .buffers
802                    .get(&self.active_buffer())
803                    .map(|s| s.buffer_settings.tab_size.to_string())
804                    .unwrap_or_else(|| "4".to_string());
805                self.start_prompt_with_initial_text(
806                    "Tab size: ".to_string(),
807                    PromptType::SetTabSize,
808                    current,
809                );
810            }
811            Action::SetLineEnding => {
812                self.start_set_line_ending_prompt();
813            }
814            Action::SetEncoding => {
815                self.start_set_encoding_prompt();
816            }
817            Action::ReloadWithEncoding => {
818                self.start_reload_with_encoding_prompt();
819            }
820            Action::SetLanguage => {
821                self.start_set_language_prompt();
822            }
823            Action::ToggleIndentationStyle => {
824                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
825                    state.buffer_settings.use_tabs = !state.buffer_settings.use_tabs;
826                    let status = if state.buffer_settings.use_tabs {
827                        "Indentation: Tabs"
828                    } else {
829                        "Indentation: Spaces"
830                    };
831                    self.set_status_message(status.to_string());
832                }
833            }
834            Action::ToggleTabIndicators | Action::ToggleWhitespaceIndicators => {
835                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
836                    state.buffer_settings.whitespace.toggle_all();
837                    let status = if state.buffer_settings.whitespace.any_visible() {
838                        t!("toggle.whitespace_indicators_shown")
839                    } else {
840                        t!("toggle.whitespace_indicators_hidden")
841                    };
842                    self.set_status_message(status.to_string());
843                }
844            }
845            Action::ResetBufferSettings => self.reset_buffer_settings(),
846            Action::FocusFileExplorer => self.focus_file_explorer(),
847            Action::FocusEditor => self.focus_editor(),
848            Action::FileExplorerUp => self.file_explorer_navigate_up(),
849            Action::FileExplorerDown => self.file_explorer_navigate_down(),
850            Action::FileExplorerPageUp => self.file_explorer_page_up(),
851            Action::FileExplorerPageDown => self.file_explorer_page_down(),
852            Action::FileExplorerExpand => self.file_explorer_toggle_expand(),
853            Action::FileExplorerCollapse => self.file_explorer_collapse(),
854            Action::FileExplorerOpen => self.file_explorer_open_file()?,
855            Action::FileExplorerRefresh => self.file_explorer_refresh(),
856            Action::FileExplorerNewFile => self.file_explorer_new_file(),
857            Action::FileExplorerNewDirectory => self.file_explorer_new_directory(),
858            Action::FileExplorerDelete => self.file_explorer_delete(),
859            Action::FileExplorerRename => self.file_explorer_rename(),
860            Action::FileExplorerToggleHidden => self.file_explorer_toggle_hidden(),
861            Action::FileExplorerToggleGitignored => self.file_explorer_toggle_gitignored(),
862            Action::FileExplorerSearchClear => self.file_explorer_search_clear(),
863            Action::FileExplorerSearchBackspace => self.file_explorer_search_pop_char(),
864            Action::RemoveSecondaryCursors => {
865                // Convert action to events and apply them
866                if let Some(events) = self.action_to_events(Action::RemoveSecondaryCursors) {
867                    // Wrap in batch for atomic undo
868                    let batch = Event::Batch {
869                        events: events.clone(),
870                        description: "Remove secondary cursors".to_string(),
871                    };
872                    self.active_event_log_mut().append(batch.clone());
873                    self.apply_event_to_active_buffer(&batch);
874
875                    // Ensure the primary cursor is visible after removing secondary cursors
876                    let active_split = self.split_manager.active_split();
877                    let active_buffer = self.active_buffer();
878                    if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
879                        let state = self.buffers.get_mut(&active_buffer).unwrap();
880                        view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
881                    }
882                }
883            }
884
885            // Menu navigation actions
886            Action::MenuActivate => {
887                self.handle_menu_activate();
888            }
889            Action::MenuClose => {
890                self.handle_menu_close();
891            }
892            Action::MenuLeft => {
893                self.handle_menu_left();
894            }
895            Action::MenuRight => {
896                self.handle_menu_right();
897            }
898            Action::MenuUp => {
899                self.handle_menu_up();
900            }
901            Action::MenuDown => {
902                self.handle_menu_down();
903            }
904            Action::MenuExecute => {
905                if let Some(action) = self.handle_menu_execute() {
906                    return self.handle_action(action);
907                }
908            }
909            Action::MenuOpen(menu_name) => {
910                if self.config.editor.menu_bar_mnemonics {
911                    self.handle_menu_open(&menu_name);
912                }
913            }
914
915            Action::SwitchKeybindingMap(map_name) => {
916                // Check if the map exists (either built-in or user-defined)
917                let is_builtin =
918                    matches!(map_name.as_str(), "default" | "emacs" | "vscode" | "macos");
919                let is_user_defined = self.config.keybinding_maps.contains_key(&map_name);
920
921                if is_builtin || is_user_defined {
922                    // Update the active keybinding map in config
923                    self.config.active_keybinding_map = map_name.clone().into();
924
925                    // Reload the keybinding resolver with the new map
926                    self.keybindings =
927                        crate::input::keybindings::KeybindingResolver::new(&self.config);
928
929                    self.set_status_message(
930                        t!("view.keybindings_switched", map = map_name).to_string(),
931                    );
932                } else {
933                    self.set_status_message(
934                        t!("view.keybindings_unknown", map = map_name).to_string(),
935                    );
936                }
937            }
938
939            Action::SmartHome => {
940                // In composite (diff) views, use LineStart movement
941                let buffer_id = self.active_buffer();
942                if self.is_composite_buffer(buffer_id) {
943                    if let Some(_handled) =
944                        self.handle_composite_action(buffer_id, &Action::SmartHome)
945                    {
946                        return Ok(());
947                    }
948                }
949                self.smart_home();
950            }
951            Action::ToggleComment => {
952                self.toggle_comment();
953            }
954            Action::ToggleFold => {
955                self.toggle_fold_at_cursor();
956            }
957            Action::GoToMatchingBracket => {
958                self.goto_matching_bracket();
959            }
960            Action::JumpToNextError => {
961                self.jump_to_next_error();
962            }
963            Action::JumpToPreviousError => {
964                self.jump_to_previous_error();
965            }
966            Action::SetBookmark(key) => {
967                self.set_bookmark(key);
968            }
969            Action::JumpToBookmark(key) => {
970                self.jump_to_bookmark(key);
971            }
972            Action::ClearBookmark(key) => {
973                self.clear_bookmark(key);
974            }
975            Action::ListBookmarks => {
976                self.list_bookmarks();
977            }
978            Action::ToggleSearchCaseSensitive => {
979                self.search_case_sensitive = !self.search_case_sensitive;
980                let state = if self.search_case_sensitive {
981                    "enabled"
982                } else {
983                    "disabled"
984                };
985                self.set_status_message(
986                    t!("search.case_sensitive_state", state = state).to_string(),
987                );
988                // Update incremental highlights if in search prompt, otherwise re-run completed search
989                // Check prompt FIRST since we want to use current prompt input, not stale search_state
990                if let Some(prompt) = &self.prompt {
991                    if matches!(
992                        prompt.prompt_type,
993                        PromptType::Search
994                            | PromptType::ReplaceSearch
995                            | PromptType::QueryReplaceSearch
996                    ) {
997                        let query = prompt.input.clone();
998                        self.update_search_highlights(&query);
999                    }
1000                } else if let Some(search_state) = &self.search_state {
1001                    let query = search_state.query.clone();
1002                    self.perform_search(&query);
1003                }
1004            }
1005            Action::ToggleSearchWholeWord => {
1006                self.search_whole_word = !self.search_whole_word;
1007                let state = if self.search_whole_word {
1008                    "enabled"
1009                } else {
1010                    "disabled"
1011                };
1012                self.set_status_message(t!("search.whole_word_state", state = state).to_string());
1013                // Update incremental highlights if in search prompt, otherwise re-run completed search
1014                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1015                if let Some(prompt) = &self.prompt {
1016                    if matches!(
1017                        prompt.prompt_type,
1018                        PromptType::Search
1019                            | PromptType::ReplaceSearch
1020                            | PromptType::QueryReplaceSearch
1021                    ) {
1022                        let query = prompt.input.clone();
1023                        self.update_search_highlights(&query);
1024                    }
1025                } else if let Some(search_state) = &self.search_state {
1026                    let query = search_state.query.clone();
1027                    self.perform_search(&query);
1028                }
1029            }
1030            Action::ToggleSearchRegex => {
1031                self.search_use_regex = !self.search_use_regex;
1032                let state = if self.search_use_regex {
1033                    "enabled"
1034                } else {
1035                    "disabled"
1036                };
1037                self.set_status_message(t!("search.regex_state", state = state).to_string());
1038                // Update incremental highlights if in search prompt, otherwise re-run completed search
1039                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1040                if let Some(prompt) = &self.prompt {
1041                    if matches!(
1042                        prompt.prompt_type,
1043                        PromptType::Search
1044                            | PromptType::ReplaceSearch
1045                            | PromptType::QueryReplaceSearch
1046                    ) {
1047                        let query = prompt.input.clone();
1048                        self.update_search_highlights(&query);
1049                    }
1050                } else if let Some(search_state) = &self.search_state {
1051                    let query = search_state.query.clone();
1052                    self.perform_search(&query);
1053                }
1054            }
1055            Action::ToggleSearchConfirmEach => {
1056                self.search_confirm_each = !self.search_confirm_each;
1057                let state = if self.search_confirm_each {
1058                    "enabled"
1059                } else {
1060                    "disabled"
1061                };
1062                self.set_status_message(t!("search.confirm_each_state", state = state).to_string());
1063            }
1064            Action::FileBrowserToggleHidden => {
1065                // Toggle hidden files in file browser (handled via file_open_toggle_hidden)
1066                self.file_open_toggle_hidden();
1067            }
1068            Action::StartMacroRecording => {
1069                // This is a no-op; use ToggleMacroRecording instead
1070                self.set_status_message(
1071                    "Use Ctrl+Shift+R to start recording (will prompt for register)".to_string(),
1072                );
1073            }
1074            Action::StopMacroRecording => {
1075                self.stop_macro_recording();
1076            }
1077            Action::PlayMacro(key) => {
1078                self.play_macro(key);
1079            }
1080            Action::ToggleMacroRecording(key) => {
1081                self.toggle_macro_recording(key);
1082            }
1083            Action::ShowMacro(key) => {
1084                self.show_macro_in_buffer(key);
1085            }
1086            Action::ListMacros => {
1087                self.list_macros_in_buffer();
1088            }
1089            Action::PromptRecordMacro => {
1090                self.start_prompt("Record macro (0-9): ".to_string(), PromptType::RecordMacro);
1091            }
1092            Action::PromptPlayMacro => {
1093                self.start_prompt("Play macro (0-9): ".to_string(), PromptType::PlayMacro);
1094            }
1095            Action::PlayLastMacro => {
1096                if let Some(key) = self.last_macro_register {
1097                    self.play_macro(key);
1098                } else {
1099                    self.set_status_message(t!("status.no_macro_recorded").to_string());
1100                }
1101            }
1102            Action::PromptSetBookmark => {
1103                self.start_prompt("Set bookmark (0-9): ".to_string(), PromptType::SetBookmark);
1104            }
1105            Action::PromptJumpToBookmark => {
1106                self.start_prompt(
1107                    "Jump to bookmark (0-9): ".to_string(),
1108                    PromptType::JumpToBookmark,
1109                );
1110            }
1111            Action::None => {}
1112            Action::DeleteBackward => {
1113                if self.is_editing_disabled() {
1114                    self.set_status_message(t!("buffer.editing_disabled").to_string());
1115                    return Ok(());
1116                }
1117                // Normal backspace handling
1118                if let Some(events) = self.action_to_events(Action::DeleteBackward) {
1119                    if events.len() > 1 {
1120                        // Multi-cursor: use optimized bulk edit (O(n) instead of O(n²))
1121                        let description = "Delete backward".to_string();
1122                        if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description)
1123                        {
1124                            self.active_event_log_mut().append(bulk_edit);
1125                        }
1126                    } else {
1127                        for event in events {
1128                            self.active_event_log_mut().append(event.clone());
1129                            self.apply_event_to_active_buffer(&event);
1130                        }
1131                    }
1132                }
1133            }
1134            Action::PluginAction(action_name) => {
1135                tracing::debug!("handle_action: PluginAction('{}')", action_name);
1136                // Execute the plugin callback via TypeScript plugin thread
1137                // Use non-blocking version to avoid deadlock with async plugin ops
1138                #[cfg(feature = "plugins")]
1139                if let Some(result) = self.plugin_manager.execute_action_async(&action_name) {
1140                    match result {
1141                        Ok(receiver) => {
1142                            // Store pending action for processing in main loop
1143                            self.pending_plugin_actions
1144                                .push((action_name.clone(), receiver));
1145                        }
1146                        Err(e) => {
1147                            self.set_status_message(
1148                                t!("view.plugin_error", error = e.to_string()).to_string(),
1149                            );
1150                            tracing::error!("Plugin action error: {}", e);
1151                        }
1152                    }
1153                } else {
1154                    self.set_status_message(t!("status.plugin_manager_unavailable").to_string());
1155                }
1156                #[cfg(not(feature = "plugins"))]
1157                {
1158                    let _ = action_name;
1159                    self.set_status_message(
1160                        "Plugins not available (compiled without plugin support)".to_string(),
1161                    );
1162                }
1163            }
1164            Action::LoadPluginFromBuffer => {
1165                #[cfg(feature = "plugins")]
1166                {
1167                    let buffer_id = self.active_buffer();
1168                    let state = self.active_state();
1169                    let buffer = &state.buffer;
1170                    let total = buffer.total_bytes();
1171                    let content =
1172                        String::from_utf8_lossy(&buffer.slice_bytes(0..total)).to_string();
1173
1174                    // Determine if TypeScript from file extension, default to TS
1175                    let is_ts = buffer
1176                        .file_path()
1177                        .and_then(|p| p.extension())
1178                        .and_then(|e| e.to_str())
1179                        .map(|e| e == "ts" || e == "tsx")
1180                        .unwrap_or(true);
1181
1182                    // Derive plugin name from buffer filename
1183                    let name = buffer
1184                        .file_path()
1185                        .and_then(|p| p.file_name())
1186                        .and_then(|s| s.to_str())
1187                        .map(|s| s.to_string())
1188                        .unwrap_or_else(|| "buffer-plugin".to_string());
1189
1190                    match self
1191                        .plugin_manager
1192                        .load_plugin_from_source(&content, &name, is_ts)
1193                    {
1194                        Ok(()) => {
1195                            self.set_status_message(format!(
1196                                "Plugin '{}' loaded from buffer",
1197                                name
1198                            ));
1199                        }
1200                        Err(e) => {
1201                            self.set_status_message(format!("Failed to load plugin: {}", e));
1202                            tracing::error!("LoadPluginFromBuffer error: {}", e);
1203                        }
1204                    }
1205
1206                    // Set up plugin dev workspace for LSP support
1207                    self.setup_plugin_dev_lsp(buffer_id, &content);
1208                }
1209                #[cfg(not(feature = "plugins"))]
1210                {
1211                    self.set_status_message(
1212                        "Plugins not available (compiled without plugin support)".to_string(),
1213                    );
1214                }
1215            }
1216            Action::OpenTerminal => {
1217                self.open_terminal();
1218            }
1219            Action::CloseTerminal => {
1220                self.close_terminal();
1221            }
1222            Action::FocusTerminal => {
1223                // If viewing a terminal buffer, switch to terminal mode
1224                if self.is_terminal_buffer(self.active_buffer()) {
1225                    self.terminal_mode = true;
1226                    self.key_context = KeyContext::Terminal;
1227                    self.set_status_message(t!("status.terminal_mode_enabled").to_string());
1228                }
1229            }
1230            Action::TerminalEscape => {
1231                // Exit terminal mode back to editor
1232                if self.terminal_mode {
1233                    self.terminal_mode = false;
1234                    self.key_context = KeyContext::Normal;
1235                    self.set_status_message(t!("status.terminal_mode_disabled").to_string());
1236                }
1237            }
1238            Action::ToggleKeyboardCapture => {
1239                // Toggle keyboard capture mode in terminal
1240                if self.terminal_mode {
1241                    self.keyboard_capture = !self.keyboard_capture;
1242                    if self.keyboard_capture {
1243                        self.set_status_message(
1244                            "Keyboard capture ON - all keys go to terminal (F9 to toggle)"
1245                                .to_string(),
1246                        );
1247                    } else {
1248                        self.set_status_message(
1249                            "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
1250                        );
1251                    }
1252                }
1253            }
1254            Action::TerminalPaste => {
1255                // Paste clipboard contents into terminal as a single batch
1256                if self.terminal_mode {
1257                    if let Some(text) = self.clipboard.paste() {
1258                        self.send_terminal_input(text.as_bytes());
1259                    }
1260                }
1261            }
1262            Action::ShellCommand => {
1263                // Run shell command on buffer/selection, output to new buffer
1264                self.start_shell_command_prompt(false);
1265            }
1266            Action::ShellCommandReplace => {
1267                // Run shell command on buffer/selection, replace content
1268                self.start_shell_command_prompt(true);
1269            }
1270            Action::OpenSettings => {
1271                self.open_settings();
1272            }
1273            Action::CloseSettings => {
1274                // Check if there are unsaved changes
1275                let has_changes = self
1276                    .settings_state
1277                    .as_ref()
1278                    .is_some_and(|s| s.has_changes());
1279                if has_changes {
1280                    // Show confirmation dialog
1281                    if let Some(ref mut state) = self.settings_state {
1282                        state.show_confirm_dialog();
1283                    }
1284                } else {
1285                    self.close_settings(false);
1286                }
1287            }
1288            Action::SettingsSave => {
1289                self.save_settings();
1290            }
1291            Action::SettingsReset => {
1292                if let Some(ref mut state) = self.settings_state {
1293                    state.reset_current_to_default();
1294                }
1295            }
1296            Action::SettingsToggleFocus => {
1297                if let Some(ref mut state) = self.settings_state {
1298                    state.toggle_focus();
1299                }
1300            }
1301            Action::SettingsActivate => {
1302                self.settings_activate_current();
1303            }
1304            Action::SettingsSearch => {
1305                if let Some(ref mut state) = self.settings_state {
1306                    state.start_search();
1307                }
1308            }
1309            Action::SettingsHelp => {
1310                if let Some(ref mut state) = self.settings_state {
1311                    state.toggle_help();
1312                }
1313            }
1314            Action::SettingsIncrement => {
1315                self.settings_increment_current();
1316            }
1317            Action::SettingsDecrement => {
1318                self.settings_decrement_current();
1319            }
1320            Action::CalibrateInput => {
1321                self.open_calibration_wizard();
1322            }
1323            Action::EventDebug => {
1324                self.open_event_debug();
1325            }
1326            Action::OpenKeybindingEditor => {
1327                self.open_keybinding_editor();
1328            }
1329            Action::PromptConfirm => {
1330                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1331                    use super::prompt_actions::PromptResult;
1332                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1333                        PromptResult::ExecuteAction(action) => {
1334                            return self.handle_action(action);
1335                        }
1336                        PromptResult::EarlyReturn => {
1337                            return Ok(());
1338                        }
1339                        PromptResult::Done => {}
1340                    }
1341                }
1342            }
1343            Action::PromptConfirmWithText(ref text) => {
1344                // For macro playback: set the prompt text before confirming
1345                if let Some(ref mut prompt) = self.prompt {
1346                    prompt.set_input(text.clone());
1347                    self.update_prompt_suggestions();
1348                }
1349                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1350                    use super::prompt_actions::PromptResult;
1351                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1352                        PromptResult::ExecuteAction(action) => {
1353                            return self.handle_action(action);
1354                        }
1355                        PromptResult::EarlyReturn => {
1356                            return Ok(());
1357                        }
1358                        PromptResult::Done => {}
1359                    }
1360                }
1361            }
1362            Action::PopupConfirm => {
1363                use super::popup_actions::PopupConfirmResult;
1364                if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
1365                    return Ok(());
1366                }
1367            }
1368            Action::PopupCancel => {
1369                self.handle_popup_cancel();
1370            }
1371            Action::InsertChar(c) => {
1372                if self.is_prompting() {
1373                    return self.handle_insert_char_prompt(c);
1374                } else if self.key_context == KeyContext::FileExplorer {
1375                    self.file_explorer_search_push_char(c);
1376                } else {
1377                    self.handle_insert_char_editor(c)?;
1378                }
1379            }
1380            // Prompt clipboard actions
1381            Action::PromptCopy => {
1382                if let Some(prompt) = &self.prompt {
1383                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1384                    if !text.is_empty() {
1385                        self.clipboard.copy(text);
1386                        self.set_status_message(t!("clipboard.copied").to_string());
1387                    }
1388                }
1389            }
1390            Action::PromptCut => {
1391                if let Some(prompt) = &self.prompt {
1392                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1393                    if !text.is_empty() {
1394                        self.clipboard.copy(text);
1395                    }
1396                }
1397                if let Some(prompt) = self.prompt.as_mut() {
1398                    if prompt.has_selection() {
1399                        prompt.delete_selection();
1400                    } else {
1401                        prompt.clear();
1402                    }
1403                }
1404                self.set_status_message(t!("clipboard.cut").to_string());
1405                self.update_prompt_suggestions();
1406            }
1407            Action::PromptPaste => {
1408                if let Some(text) = self.clipboard.paste() {
1409                    if let Some(prompt) = self.prompt.as_mut() {
1410                        prompt.insert_str(&text);
1411                    }
1412                    self.update_prompt_suggestions();
1413                }
1414            }
1415            _ => {
1416                // TODO: Why do we have this catch-all? It seems like actions should either:
1417                // 1. Be handled explicitly above (like InsertChar, PopupConfirm, etc.)
1418                // 2. Or be converted to events consistently
1419                // This catch-all makes it unclear which actions go through event conversion
1420                // vs. direct handling. Consider making this explicit or removing the pattern.
1421                self.apply_action_as_events(action)?;
1422            }
1423        }
1424
1425        Ok(())
1426    }
1427
1428    /// Handle mouse wheel scroll event
1429    pub(super) fn handle_mouse_scroll(
1430        &mut self,
1431        col: u16,
1432        row: u16,
1433        delta: i32,
1434    ) -> AnyhowResult<()> {
1435        // Notify plugins of mouse scroll so they can handle it for virtual buffers
1436        let buffer_id = self.active_buffer();
1437        self.plugin_manager.run_hook(
1438            "mouse_scroll",
1439            fresh_core::hooks::HookArgs::MouseScroll {
1440                buffer_id,
1441                delta,
1442                col,
1443                row,
1444            },
1445        );
1446
1447        // Check if scroll is over the file explorer
1448        if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1449            if col >= explorer_area.x
1450                && col < explorer_area.x + explorer_area.width
1451                && row >= explorer_area.y
1452                && row < explorer_area.y + explorer_area.height
1453            {
1454                // Scroll the file explorer
1455                if let Some(explorer) = &mut self.file_explorer {
1456                    let count = explorer.visible_count();
1457                    if count == 0 {
1458                        return Ok(());
1459                    }
1460
1461                    // Get current selected index
1462                    let current_index = explorer.get_selected_index().unwrap_or(0);
1463
1464                    // Calculate new index based on scroll delta
1465                    let new_index = if delta < 0 {
1466                        // Scroll up (negative delta)
1467                        current_index.saturating_sub(delta.unsigned_abs() as usize)
1468                    } else {
1469                        // Scroll down (positive delta)
1470                        (current_index + delta as usize).min(count - 1)
1471                    };
1472
1473                    // Set the new selection
1474                    if let Some(node_id) = explorer.get_node_at_index(new_index) {
1475                        explorer.set_selected(Some(node_id));
1476                        explorer.update_scroll_for_selection();
1477                    }
1478                }
1479                return Ok(());
1480            }
1481        }
1482
1483        // Scroll the split under the mouse pointer (not necessarily the focused split).
1484        // Fall back to the active split if the pointer isn't over any split area.
1485        let (target_split, buffer_id) = self
1486            .split_at_position(col, row)
1487            .unwrap_or_else(|| (self.split_manager.active_split(), self.active_buffer()));
1488
1489        // Check if this is a composite buffer - if so, use composite scroll
1490        if self.is_composite_buffer(buffer_id) {
1491            let max_row = self
1492                .composite_buffers
1493                .get(&buffer_id)
1494                .map(|c| c.row_count().saturating_sub(1))
1495                .unwrap_or(0);
1496            if let Some(view_state) = self
1497                .composite_view_states
1498                .get_mut(&(target_split, buffer_id))
1499            {
1500                view_state.scroll(delta as isize, max_row);
1501                tracing::trace!(
1502                    "handle_mouse_scroll (composite): delta={}, scroll_row={}",
1503                    delta,
1504                    view_state.scroll_row
1505                );
1506            }
1507            return Ok(());
1508        }
1509
1510        // Get view_transform tokens from SplitViewState (if any)
1511        let view_transform_tokens = self
1512            .split_view_states
1513            .get(&target_split)
1514            .and_then(|vs| vs.view_transform.as_ref())
1515            .map(|vt| vt.tokens.clone());
1516
1517        // Get mutable references to both buffer state and view state
1518        let state = self.buffers.get_mut(&buffer_id);
1519        let view_state = self.split_view_states.get_mut(&target_split);
1520
1521        if let (Some(state), Some(view_state)) = (state, view_state) {
1522            let buffer = &mut state.buffer;
1523            let top_byte_before = view_state.viewport.top_byte;
1524            if let Some(tokens) = view_transform_tokens {
1525                // Use view-aware scrolling with the transform's tokens
1526                use crate::view::ui::view_pipeline::ViewLineIterator;
1527                let tab_size = self.config.editor.tab_size;
1528                let view_lines: Vec<_> =
1529                    ViewLineIterator::new(&tokens, false, false, tab_size, false).collect();
1530                view_state
1531                    .viewport
1532                    .scroll_view_lines(&view_lines, delta as isize);
1533            } else {
1534                // No view transform - use traditional buffer-based scrolling
1535                if delta < 0 {
1536                    // Scroll up
1537                    let lines_to_scroll = delta.unsigned_abs() as usize;
1538                    view_state.viewport.scroll_up(buffer, lines_to_scroll);
1539                } else {
1540                    // Scroll down
1541                    let lines_to_scroll = delta as usize;
1542                    view_state.viewport.scroll_down(buffer, lines_to_scroll);
1543                }
1544            }
1545            // Skip ensure_visible so the scroll position isn't undone during render
1546            view_state.viewport.set_skip_ensure_visible();
1547
1548            if let Some(folds) = view_state.keyed_states.get(&buffer_id).map(|bs| &bs.folds) {
1549                if !folds.is_empty() {
1550                    let top_line = buffer.get_line_number(view_state.viewport.top_byte);
1551                    if let Some(range) = folds
1552                        .resolved_ranges(buffer, &state.marker_list)
1553                        .iter()
1554                        .find(|r| top_line >= r.start_line && top_line <= r.end_line)
1555                    {
1556                        let target_line = if delta >= 0 {
1557                            range.end_line.saturating_add(1)
1558                        } else {
1559                            range.header_line
1560                        };
1561                        let target_byte = buffer
1562                            .line_start_offset(target_line)
1563                            .unwrap_or_else(|| buffer.len());
1564                        view_state.viewport.top_byte = target_byte;
1565                        view_state.viewport.top_view_line_offset = 0;
1566                    }
1567                }
1568            }
1569            tracing::trace!(
1570                "handle_mouse_scroll: delta={}, top_byte {} -> {}",
1571                delta,
1572                top_byte_before,
1573                view_state.viewport.top_byte
1574            );
1575        }
1576
1577        Ok(())
1578    }
1579
1580    /// Handle horizontal scroll (Shift+ScrollWheel or native ScrollLeft/ScrollRight)
1581    pub(super) fn handle_horizontal_scroll(
1582        &mut self,
1583        col: u16,
1584        row: u16,
1585        delta: i32,
1586    ) -> AnyhowResult<()> {
1587        let target_split = self
1588            .split_at_position(col, row)
1589            .map(|(id, _)| id)
1590            .unwrap_or_else(|| self.split_manager.active_split());
1591
1592        if let Some(view_state) = self.split_view_states.get_mut(&target_split) {
1593            // Don't scroll horizontally when line wrap is enabled
1594            if view_state.viewport.line_wrap_enabled {
1595                return Ok(());
1596            }
1597
1598            let columns_to_scroll = delta.unsigned_abs() as usize;
1599            if delta < 0 {
1600                // Scroll left
1601                view_state.viewport.left_column = view_state
1602                    .viewport
1603                    .left_column
1604                    .saturating_sub(columns_to_scroll);
1605            } else {
1606                // Scroll right - clamp to max_line_length_seen
1607                let visible_width = view_state.viewport.width as usize;
1608                let max_scroll = view_state
1609                    .viewport
1610                    .max_line_length_seen
1611                    .saturating_sub(visible_width);
1612                let new_left = view_state
1613                    .viewport
1614                    .left_column
1615                    .saturating_add(columns_to_scroll);
1616                view_state.viewport.left_column = new_left.min(max_scroll);
1617            }
1618            // Skip ensure_visible so the scroll position isn't undone during render
1619            view_state.viewport.set_skip_ensure_visible();
1620        }
1621
1622        Ok(())
1623    }
1624
1625    /// Handle scrollbar drag with relative movement (when dragging from thumb)
1626    pub(super) fn handle_scrollbar_drag_relative(
1627        &mut self,
1628        row: u16,
1629        split_id: LeafId,
1630        buffer_id: BufferId,
1631        scrollbar_rect: ratatui::layout::Rect,
1632    ) -> AnyhowResult<()> {
1633        let drag_start_row = match self.mouse_state.drag_start_row {
1634            Some(r) => r,
1635            None => return Ok(()), // No drag start, shouldn't happen
1636        };
1637
1638        // Handle composite buffers - use row-based scrolling
1639        if self.is_composite_buffer(buffer_id) {
1640            return self.handle_composite_scrollbar_drag_relative(
1641                row,
1642                drag_start_row,
1643                split_id,
1644                buffer_id,
1645                scrollbar_rect,
1646            );
1647        }
1648
1649        let drag_start_top_byte = match self.mouse_state.drag_start_top_byte {
1650            Some(b) => b,
1651            None => return Ok(()), // No drag start, shouldn't happen
1652        };
1653
1654        let drag_start_view_line_offset = self.mouse_state.drag_start_view_line_offset.unwrap_or(0);
1655
1656        // Calculate the offset in rows (still used for large files)
1657        let row_offset = (row as i32) - (drag_start_row as i32);
1658
1659        // Get viewport height from SplitViewState
1660        let viewport_height = self
1661            .split_view_states
1662            .get(&split_id)
1663            .map(|vs| vs.viewport.height as usize)
1664            .unwrap_or(10);
1665
1666        // Check if line wrapping is enabled
1667        let line_wrap_enabled = self
1668            .split_view_states
1669            .get(&split_id)
1670            .map(|vs| vs.viewport.line_wrap_enabled)
1671            .unwrap_or(false);
1672
1673        let viewport_width = self
1674            .split_view_states
1675            .get(&split_id)
1676            .map(|vs| vs.viewport.width as usize)
1677            .unwrap_or(80);
1678
1679        // Get the buffer state and calculate target position using RELATIVE movement
1680        // Returns (byte_position, view_line_offset) for proper positioning within wrapped lines
1681        let scroll_position = if let Some(state) = self.buffers.get_mut(&buffer_id) {
1682            let scrollbar_height = scrollbar_rect.height as usize;
1683            if scrollbar_height == 0 {
1684                return Ok(());
1685            }
1686
1687            let buffer_len = state.buffer.len();
1688            let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
1689
1690            // Use relative movement: calculate scroll change based on row_offset from drag start
1691            if buffer_len <= large_file_threshold {
1692                // When line wrapping is enabled, use visual row calculations
1693                if line_wrap_enabled {
1694                    Self::calculate_scrollbar_drag_relative_visual(
1695                        &mut state.buffer,
1696                        row,
1697                        scrollbar_rect.y,
1698                        scrollbar_height,
1699                        drag_start_row,
1700                        drag_start_top_byte,
1701                        drag_start_view_line_offset,
1702                        viewport_height,
1703                        viewport_width,
1704                    )
1705                } else {
1706                    // Small file without line wrap: thumb follows mouse
1707                    let total_lines = if buffer_len > 0 {
1708                        state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
1709                    } else {
1710                        1
1711                    };
1712
1713                    let max_scroll_line = total_lines.saturating_sub(viewport_height);
1714
1715                    if max_scroll_line == 0 || scrollbar_height <= 1 {
1716                        // File fits in viewport, no scrolling
1717                        (0, 0)
1718                    } else {
1719                        // Find the starting line number from drag_start_top_byte
1720                        let start_line = state.buffer.get_line_number(drag_start_top_byte);
1721
1722                        // Calculate thumb size (same formula as scrollbar rendering)
1723                        let thumb_size_raw = (viewport_height as f64 / total_lines as f64
1724                            * scrollbar_height as f64)
1725                            .ceil() as usize;
1726                        let max_thumb_size = (scrollbar_height as f64 * 0.8).floor() as usize;
1727                        let thumb_size = thumb_size_raw
1728                            .max(1)
1729                            .min(max_thumb_size)
1730                            .min(scrollbar_height);
1731
1732                        // Calculate max thumb start position (same as scrollbar rendering)
1733                        let max_thumb_start = scrollbar_height.saturating_sub(thumb_size);
1734
1735                        if max_thumb_start == 0 {
1736                            // Thumb fills the track, no dragging possible
1737                            (0, 0)
1738                        } else {
1739                            // Calculate where the thumb was at drag start
1740                            let start_scroll_ratio =
1741                                start_line.min(max_scroll_line) as f64 / max_scroll_line as f64;
1742                            let thumb_row_at_start = scrollbar_rect.y as f64
1743                                + start_scroll_ratio * max_thumb_start as f64;
1744
1745                            // Calculate click offset (where on thumb we clicked)
1746                            let click_offset = drag_start_row as f64 - thumb_row_at_start;
1747
1748                            // Target thumb position based on current mouse position
1749                            let target_thumb_row = row as f64 - click_offset;
1750
1751                            // Map target thumb position to scroll ratio
1752                            let target_scroll_ratio = ((target_thumb_row
1753                                - scrollbar_rect.y as f64)
1754                                / max_thumb_start as f64)
1755                                .clamp(0.0, 1.0);
1756
1757                            // Map scroll ratio to target line
1758                            let target_line =
1759                                (target_scroll_ratio * max_scroll_line as f64).round() as usize;
1760                            let target_line = target_line.min(max_scroll_line);
1761
1762                            // Find byte position of target line
1763                            let target_byte = state
1764                                .buffer
1765                                .line_start_offset(target_line)
1766                                .unwrap_or(drag_start_top_byte);
1767
1768                            (target_byte, 0)
1769                        }
1770                    }
1771                }
1772            } else {
1773                // Large file: use byte-based relative movement
1774                let bytes_per_pixel = buffer_len as f64 / scrollbar_height as f64;
1775                let byte_offset = (row_offset as f64 * bytes_per_pixel) as i64;
1776
1777                let new_top_byte = if byte_offset >= 0 {
1778                    drag_start_top_byte.saturating_add(byte_offset as usize)
1779                } else {
1780                    drag_start_top_byte.saturating_sub((-byte_offset) as usize)
1781                };
1782
1783                // Clamp to valid range using byte-based max (avoid iterating entire buffer)
1784                let new_top_byte = new_top_byte.min(buffer_len.saturating_sub(1));
1785
1786                // Find the line start for this byte position
1787                let iter = state.buffer.line_iterator(new_top_byte, 80);
1788                (iter.current_position(), 0)
1789            }
1790        } else {
1791            return Ok(());
1792        };
1793
1794        // Set viewport top to this position in SplitViewState
1795        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1796            view_state.viewport.top_byte = scroll_position.0;
1797            view_state.viewport.top_view_line_offset = scroll_position.1;
1798            // Skip ensure_visible so the scroll position isn't undone during render
1799            view_state.viewport.set_skip_ensure_visible();
1800        }
1801
1802        // Move cursor to be visible in the new viewport (after releasing the state borrow)
1803        self.move_cursor_to_visible_area(split_id, buffer_id);
1804
1805        Ok(())
1806    }
1807
1808    /// Handle scrollbar jump (clicking on track or absolute positioning)
1809    pub(super) fn handle_scrollbar_jump(
1810        &mut self,
1811        _col: u16,
1812        row: u16,
1813        split_id: LeafId,
1814        buffer_id: BufferId,
1815        scrollbar_rect: ratatui::layout::Rect,
1816    ) -> AnyhowResult<()> {
1817        // Calculate which line to scroll to based on mouse position
1818        let scrollbar_height = scrollbar_rect.height as usize;
1819        if scrollbar_height == 0 {
1820            return Ok(());
1821        }
1822
1823        // Get relative position in scrollbar (0.0 to 1.0)
1824        // Divide by (height - 1) to map first row to 0.0 and last row to 1.0
1825        let relative_row = row.saturating_sub(scrollbar_rect.y);
1826        let ratio = if scrollbar_height > 1 {
1827            ((relative_row as f64) / ((scrollbar_height - 1) as f64)).clamp(0.0, 1.0)
1828        } else {
1829            0.0
1830        };
1831
1832        // Handle composite buffers - use row-based scrolling
1833        if self.is_composite_buffer(buffer_id) {
1834            return self.handle_composite_scrollbar_jump(
1835                ratio,
1836                split_id,
1837                buffer_id,
1838                scrollbar_rect,
1839            );
1840        }
1841
1842        // Get viewport height from SplitViewState
1843        let viewport_height = self
1844            .split_view_states
1845            .get(&split_id)
1846            .map(|vs| vs.viewport.height as usize)
1847            .unwrap_or(10);
1848
1849        // Check if line wrapping is enabled
1850        let line_wrap_enabled = self
1851            .split_view_states
1852            .get(&split_id)
1853            .map(|vs| vs.viewport.line_wrap_enabled)
1854            .unwrap_or(false);
1855
1856        let viewport_width = self
1857            .split_view_states
1858            .get(&split_id)
1859            .map(|vs| vs.viewport.width as usize)
1860            .unwrap_or(80);
1861
1862        // Get the buffer state and calculate scroll position
1863        // Returns (byte_position, view_line_offset) for proper positioning within wrapped lines
1864        let scroll_position = if let Some(state) = self.buffers.get_mut(&buffer_id) {
1865            let buffer_len = state.buffer.len();
1866            let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
1867
1868            // For small files, use precise line-based calculations
1869            // For large files, fall back to byte-based estimation
1870            if buffer_len <= large_file_threshold {
1871                // When line wrapping is enabled, use visual row calculations
1872                if line_wrap_enabled {
1873                    // calculate_scrollbar_jump_visual already handles max scroll limiting
1874                    // and returns both byte position and view line offset
1875                    Self::calculate_scrollbar_jump_visual(
1876                        &mut state.buffer,
1877                        ratio,
1878                        viewport_height,
1879                        viewport_width,
1880                    )
1881                } else {
1882                    // Small file without line wrap: use line-based calculation for precision
1883                    let total_lines = if buffer_len > 0 {
1884                        state.buffer.get_line_number(buffer_len.saturating_sub(1)) + 1
1885                    } else {
1886                        1
1887                    };
1888
1889                    let max_scroll_line = total_lines.saturating_sub(viewport_height);
1890
1891                    let target_byte = if max_scroll_line == 0 {
1892                        // File fits in viewport, no scrolling
1893                        0
1894                    } else {
1895                        // Map ratio to target line
1896                        let target_line = (ratio * max_scroll_line as f64).round() as usize;
1897                        let target_line = target_line.min(max_scroll_line);
1898
1899                        // Find byte position of target line
1900                        // We need to iterate 'target_line' times to skip past lines 0..target_line-1,
1901                        // then one more time to get the position of line 'target_line'
1902                        let mut iter = state.buffer.line_iterator(0, 80);
1903                        let mut line_byte = 0;
1904
1905                        for _ in 0..target_line {
1906                            if let Some((pos, _content)) = iter.next_line() {
1907                                line_byte = pos;
1908                            } else {
1909                                break;
1910                            }
1911                        }
1912
1913                        // Get the position of the target line
1914                        if let Some((pos, _)) = iter.next_line() {
1915                            pos
1916                        } else {
1917                            line_byte // Reached end of buffer
1918                        }
1919                    };
1920
1921                    // Find the line start for this byte position
1922                    let iter = state.buffer.line_iterator(target_byte, 80);
1923                    let line_start = iter.current_position();
1924
1925                    // Apply scroll limiting
1926                    let max_top_byte =
1927                        Self::calculate_max_scroll_position(&mut state.buffer, viewport_height);
1928                    (line_start.min(max_top_byte), 0)
1929                }
1930            } else {
1931                // Large file: use byte-based estimation (original logic)
1932                let target_byte = (buffer_len as f64 * ratio) as usize;
1933                let target_byte = target_byte.min(buffer_len.saturating_sub(1));
1934
1935                // Find the line start for this byte position
1936                let iter = state.buffer.line_iterator(target_byte, 80);
1937                let line_start = iter.current_position();
1938
1939                (line_start.min(buffer_len.saturating_sub(1)), 0)
1940            }
1941        } else {
1942            return Ok(());
1943        };
1944
1945        // Set viewport top to this position in SplitViewState
1946        if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1947            view_state.viewport.top_byte = scroll_position.0;
1948            view_state.viewport.top_view_line_offset = scroll_position.1;
1949            // Skip ensure_visible so the scroll position isn't undone during render
1950            view_state.viewport.set_skip_ensure_visible();
1951        }
1952
1953        // Move cursor to be visible in the new viewport (after releasing the state borrow)
1954        self.move_cursor_to_visible_area(split_id, buffer_id);
1955
1956        Ok(())
1957    }
1958
1959    /// Handle scrollbar jump (click on track) for composite buffers.
1960    /// Maps the click ratio to a row-based scroll position.
1961    fn handle_composite_scrollbar_jump(
1962        &mut self,
1963        ratio: f64,
1964        split_id: LeafId,
1965        buffer_id: BufferId,
1966        scrollbar_rect: ratatui::layout::Rect,
1967    ) -> AnyhowResult<()> {
1968        let total_rows = self
1969            .composite_buffers
1970            .get(&buffer_id)
1971            .map(|c| c.row_count())
1972            .unwrap_or(0);
1973        let content_height = scrollbar_rect.height.saturating_sub(1) as usize;
1974        let max_scroll_row = total_rows.saturating_sub(content_height);
1975        let target_row = (ratio * max_scroll_row as f64).round() as usize;
1976        let target_row = target_row.min(max_scroll_row);
1977
1978        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
1979            view_state.set_scroll_row(target_row, max_scroll_row);
1980        }
1981        Ok(())
1982    }
1983
1984    /// Handle scrollbar thumb drag for composite buffers.
1985    /// Uses relative movement from the drag start position.
1986    fn handle_composite_scrollbar_drag_relative(
1987        &mut self,
1988        row: u16,
1989        drag_start_row: u16,
1990        split_id: LeafId,
1991        buffer_id: BufferId,
1992        scrollbar_rect: ratatui::layout::Rect,
1993    ) -> AnyhowResult<()> {
1994        let drag_start_scroll_row = match self.mouse_state.drag_start_composite_scroll_row {
1995            Some(r) => r,
1996            None => return Ok(()),
1997        };
1998
1999        let total_rows = self
2000            .composite_buffers
2001            .get(&buffer_id)
2002            .map(|c| c.row_count())
2003            .unwrap_or(0);
2004        let content_height = scrollbar_rect.height.saturating_sub(1) as usize;
2005        let max_scroll_row = total_rows.saturating_sub(content_height);
2006
2007        if max_scroll_row == 0 {
2008            return Ok(());
2009        }
2010
2011        let scrollbar_height = scrollbar_rect.height as usize;
2012        if scrollbar_height <= 1 {
2013            return Ok(());
2014        }
2015
2016        // Calculate thumb size (same formula as render_composite_scrollbar)
2017        let thumb_size_raw =
2018            (content_height as f64 / total_rows as f64 * scrollbar_height as f64).ceil() as usize;
2019        let max_thumb_size = (scrollbar_height as f64 * 0.8).floor() as usize;
2020        let thumb_size = thumb_size_raw
2021            .max(1)
2022            .min(max_thumb_size)
2023            .min(scrollbar_height);
2024        let max_thumb_start = scrollbar_height.saturating_sub(thumb_size);
2025
2026        if max_thumb_start == 0 {
2027            return Ok(());
2028        }
2029
2030        // Calculate where the thumb was at drag start
2031        let start_scroll_ratio =
2032            drag_start_scroll_row.min(max_scroll_row) as f64 / max_scroll_row as f64;
2033        let thumb_row_at_start =
2034            scrollbar_rect.y as f64 + start_scroll_ratio * max_thumb_start as f64;
2035
2036        // Calculate click offset (where on thumb we clicked)
2037        let click_offset = drag_start_row as f64 - thumb_row_at_start;
2038
2039        // Target thumb position based on current mouse position
2040        let target_thumb_row = row as f64 - click_offset;
2041
2042        // Map target thumb position to scroll ratio
2043        let target_scroll_ratio =
2044            ((target_thumb_row - scrollbar_rect.y as f64) / max_thumb_start as f64).clamp(0.0, 1.0);
2045
2046        // Map scroll ratio to target row
2047        let target_row = (target_scroll_ratio * max_scroll_row as f64).round() as usize;
2048        let target_row = target_row.min(max_scroll_row);
2049
2050        if let Some(view_state) = self.composite_view_states.get_mut(&(split_id, buffer_id)) {
2051            view_state.set_scroll_row(target_row, max_scroll_row);
2052        }
2053        Ok(())
2054    }
2055
2056    /// Move the cursor to a visible position within the current viewport
2057    /// This is called after scrollbar operations to ensure the cursor is in view
2058    pub(super) fn move_cursor_to_visible_area(&mut self, split_id: LeafId, buffer_id: BufferId) {
2059        // Get viewport info from SplitViewState
2060        let (top_byte, viewport_height) =
2061            if let Some(view_state) = self.split_view_states.get(&split_id) {
2062                (
2063                    view_state.viewport.top_byte,
2064                    view_state.viewport.height as usize,
2065                )
2066            } else {
2067                return;
2068            };
2069
2070        if let Some(state) = self.buffers.get_mut(&buffer_id) {
2071            let buffer_len = state.buffer.len();
2072
2073            // Find the bottom byte of the viewport
2074            // We iterate through viewport_height lines starting from top_byte
2075            let mut iter = state.buffer.line_iterator(top_byte, 80);
2076            let mut bottom_byte = buffer_len;
2077
2078            // Consume viewport_height lines to find where the visible area ends
2079            for _ in 0..viewport_height {
2080                if let Some((pos, line)) = iter.next_line() {
2081                    // The bottom of this line is at pos + line.len()
2082                    bottom_byte = pos + line.len();
2083                } else {
2084                    // Reached end of buffer
2085                    bottom_byte = buffer_len;
2086                    break;
2087                }
2088            }
2089
2090            // Check if cursor is outside visible range and move it if needed
2091            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
2092                let cursor_pos = view_state.cursors.primary().position;
2093                if cursor_pos < top_byte || cursor_pos > bottom_byte {
2094                    // Move cursor to the top of the viewport
2095                    let cursor = view_state.cursors.primary_mut();
2096                    cursor.position = top_byte;
2097                    // Keep the existing sticky_column value so vertical navigation preserves column
2098                }
2099            }
2100        }
2101    }
2102
2103    /// Calculate the maximum allowed scroll position
2104    /// Ensures the last line is always at the bottom unless the buffer is smaller than viewport
2105    pub(super) fn calculate_max_scroll_position(
2106        buffer: &mut crate::model::buffer::Buffer,
2107        viewport_height: usize,
2108    ) -> usize {
2109        if viewport_height == 0 {
2110            return 0;
2111        }
2112
2113        let buffer_len = buffer.len();
2114        if buffer_len == 0 {
2115            return 0;
2116        }
2117
2118        // Count total lines in buffer
2119        let mut line_count = 0;
2120        let mut iter = buffer.line_iterator(0, 80);
2121        while iter.next_line().is_some() {
2122            line_count += 1;
2123        }
2124
2125        // If buffer has fewer lines than viewport, can't scroll at all
2126        if line_count <= viewport_height {
2127            return 0;
2128        }
2129
2130        // Calculate how many lines from the start we can scroll
2131        // We want to be able to scroll so that the last line is at the bottom
2132        let scrollable_lines = line_count.saturating_sub(viewport_height);
2133
2134        // Find the byte position of the line at scrollable_lines offset
2135        let mut iter = buffer.line_iterator(0, 80);
2136        let mut current_line = 0;
2137        let mut max_byte_pos = 0;
2138
2139        while current_line < scrollable_lines {
2140            if let Some((pos, _content)) = iter.next_line() {
2141                max_byte_pos = pos;
2142                current_line += 1;
2143            } else {
2144                break;
2145            }
2146        }
2147
2148        max_byte_pos
2149    }
2150
2151    /// Calculate scrollbar jump position using visual rows (for line-wrapped content)
2152    /// Returns the byte position to scroll to based on the scroll ratio
2153    /// Calculate scroll position for visual-row-aware scrollbar jump.
2154    /// Returns (byte_position, view_line_offset) for proper positioning within wrapped lines.
2155    fn calculate_scrollbar_jump_visual(
2156        buffer: &mut crate::model::buffer::Buffer,
2157        ratio: f64,
2158        viewport_height: usize,
2159        viewport_width: usize,
2160    ) -> (usize, usize) {
2161        use crate::primitives::line_wrapping::{wrap_line, WrapConfig};
2162
2163        let buffer_len = buffer.len();
2164        if buffer_len == 0 || viewport_height == 0 {
2165            return (0, 0);
2166        }
2167
2168        // Calculate gutter width (estimate based on line count)
2169        let line_count = buffer.line_count().unwrap_or(1);
2170        let digits = (line_count as f64).log10().floor() as usize + 1;
2171        let gutter_width = 1 + digits.max(4) + 3; // indicator + digits + separator
2172
2173        let wrap_config = WrapConfig::new(viewport_width, gutter_width, true, true);
2174
2175        // Count total visual rows and build a map of visual row -> (line_byte, offset_in_line)
2176        let mut total_visual_rows = 0;
2177        let mut visual_row_positions: Vec<(usize, usize)> = Vec::new(); // (line_start_byte, visual_row_offset)
2178
2179        let mut iter = buffer.line_iterator(0, 80);
2180        while let Some((line_start, content)) = iter.next_line() {
2181            let line_content = content.trim_end_matches(['\n', '\r']).to_string();
2182            let segments = wrap_line(&line_content, &wrap_config);
2183            let visual_rows_in_line = segments.len().max(1);
2184
2185            for offset in 0..visual_rows_in_line {
2186                visual_row_positions.push((line_start, offset));
2187            }
2188            total_visual_rows += visual_rows_in_line;
2189        }
2190
2191        if total_visual_rows == 0 {
2192            return (0, 0);
2193        }
2194
2195        // Calculate max scroll visual row (leave viewport_height rows visible at bottom)
2196        let max_scroll_row = total_visual_rows.saturating_sub(viewport_height);
2197
2198        if max_scroll_row == 0 {
2199            // Content fits in viewport, no scrolling needed
2200            return (0, 0);
2201        }
2202
2203        // Map ratio to target visual row
2204        let target_row = (ratio * max_scroll_row as f64).round() as usize;
2205        let target_row = target_row.min(max_scroll_row);
2206
2207        // Get the byte position and offset for this visual row
2208        if target_row < visual_row_positions.len() {
2209            visual_row_positions[target_row]
2210        } else {
2211            // Fallback to last position
2212            visual_row_positions.last().copied().unwrap_or((0, 0))
2213        }
2214    }
2215
2216    /// Calculate scroll position for visual-row-aware scrollbar drag.
2217    /// The thumb follows the mouse position, accounting for where on the thumb the user clicked.
2218    /// Returns (byte_position, view_line_offset) for proper positioning within wrapped lines.
2219    #[allow(clippy::too_many_arguments)]
2220    fn calculate_scrollbar_drag_relative_visual(
2221        buffer: &mut crate::model::buffer::Buffer,
2222        current_row: u16,
2223        scrollbar_y: u16,
2224        scrollbar_height: usize,
2225        drag_start_row: u16,
2226        drag_start_top_byte: usize,
2227        drag_start_view_line_offset: usize,
2228        viewport_height: usize,
2229        viewport_width: usize,
2230    ) -> (usize, usize) {
2231        use crate::primitives::line_wrapping::{wrap_line, WrapConfig};
2232
2233        let buffer_len = buffer.len();
2234        if buffer_len == 0 || viewport_height == 0 || scrollbar_height <= 1 {
2235            return (0, 0);
2236        }
2237
2238        // Calculate gutter width (estimate based on line count)
2239        let line_count = buffer.line_count().unwrap_or(1);
2240        let digits = (line_count as f64).log10().floor() as usize + 1;
2241        let gutter_width = 1 + digits.max(4) + 3; // indicator + digits + separator
2242
2243        let wrap_config = WrapConfig::new(viewport_width, gutter_width, true, true);
2244
2245        // Build visual row positions map
2246        let mut total_visual_rows = 0;
2247        let mut visual_row_positions: Vec<(usize, usize)> = Vec::new();
2248
2249        let mut iter = buffer.line_iterator(0, 80);
2250        while let Some((line_start, content)) = iter.next_line() {
2251            let line_content = content.trim_end_matches(['\n', '\r']).to_string();
2252            let segments = wrap_line(&line_content, &wrap_config);
2253            let visual_rows_in_line = segments.len().max(1);
2254
2255            for offset in 0..visual_rows_in_line {
2256                visual_row_positions.push((line_start, offset));
2257            }
2258            total_visual_rows += visual_rows_in_line;
2259        }
2260
2261        if total_visual_rows == 0 {
2262            return (0, 0);
2263        }
2264
2265        let max_scroll_row = total_visual_rows.saturating_sub(viewport_height);
2266        if max_scroll_row == 0 {
2267            return (0, 0);
2268        }
2269
2270        // Find the visual row corresponding to drag_start_top_byte + view_line_offset
2271        // First find the line start, then add the offset for wrapped lines
2272        let line_start_visual_row = visual_row_positions
2273            .iter()
2274            .position(|(byte, _)| *byte >= drag_start_top_byte)
2275            .unwrap_or(0);
2276        let start_visual_row =
2277            (line_start_visual_row + drag_start_view_line_offset).min(max_scroll_row);
2278
2279        // Calculate thumb size (same formula as scrollbar rendering)
2280        let thumb_size_raw = (viewport_height as f64 / total_visual_rows as f64
2281            * scrollbar_height as f64)
2282            .ceil() as usize;
2283        let max_thumb_size = (scrollbar_height as f64 * 0.8).floor() as usize;
2284        let thumb_size = thumb_size_raw
2285            .max(1)
2286            .min(max_thumb_size)
2287            .min(scrollbar_height);
2288
2289        // Calculate max thumb start position (same as scrollbar rendering)
2290        let max_thumb_start = scrollbar_height.saturating_sub(thumb_size);
2291
2292        // Calculate where the thumb was (in scrollbar coordinates) at drag start
2293        // Using the same formula as scrollbar rendering: thumb_start = scroll_ratio * max_thumb_start
2294        let start_scroll_ratio = start_visual_row as f64 / max_scroll_row as f64;
2295        let thumb_row_at_start = scrollbar_y as f64 + start_scroll_ratio * max_thumb_start as f64;
2296
2297        // Calculate click offset (where on the thumb we clicked)
2298        let click_offset = drag_start_row as f64 - thumb_row_at_start;
2299
2300        // Calculate target thumb position based on current mouse position
2301        let target_thumb_row = current_row as f64 - click_offset;
2302
2303        // Map target thumb position to scroll ratio (inverse of thumb_start formula)
2304        let target_scroll_ratio = if max_thumb_start > 0 {
2305            ((target_thumb_row - scrollbar_y as f64) / max_thumb_start as f64).clamp(0.0, 1.0)
2306        } else {
2307            0.0
2308        };
2309
2310        // Map scroll ratio to visual row
2311        let target_row = (target_scroll_ratio * max_scroll_row as f64).round() as usize;
2312        let target_row = target_row.min(max_scroll_row);
2313
2314        // Get the byte position and offset for this visual row
2315        if target_row < visual_row_positions.len() {
2316            visual_row_positions[target_row]
2317        } else {
2318            visual_row_positions.last().copied().unwrap_or((0, 0))
2319        }
2320    }
2321
2322    /// Calculate buffer byte position from screen coordinates
2323    ///
2324    /// When `compose_width` is set and narrower than the content area, the
2325    /// content is centered with left padding.  View-line mappings are built
2326    /// relative to that compose render area, so the same offset must be
2327    /// applied here when converting screen coordinates.
2328    ///
2329    /// Returns None if the position cannot be determined (e.g., click in gutter for click handler)
2330    #[allow(clippy::too_many_arguments)]
2331    pub(crate) fn screen_to_buffer_position(
2332        col: u16,
2333        row: u16,
2334        content_rect: ratatui::layout::Rect,
2335        gutter_width: u16,
2336        cached_mappings: &Option<Vec<crate::app::types::ViewLineMapping>>,
2337        fallback_position: usize,
2338        allow_gutter_click: bool,
2339        compose_width: Option<u16>,
2340    ) -> Option<usize> {
2341        // Adjust content_rect for compose layout centering
2342        let content_rect = Self::adjust_content_rect_for_compose(content_rect, compose_width);
2343
2344        // Calculate relative position in content area
2345        let content_col = col.saturating_sub(content_rect.x);
2346        let content_row = row.saturating_sub(content_rect.y);
2347
2348        tracing::trace!(
2349            col,
2350            row,
2351            ?content_rect,
2352            gutter_width,
2353            content_col,
2354            content_row,
2355            num_mappings = cached_mappings.as_ref().map(|m| m.len()),
2356            "screen_to_buffer_position"
2357        );
2358
2359        // Handle gutter clicks
2360        let text_col = if content_col < gutter_width {
2361            if !allow_gutter_click {
2362                return None; // Click handler skips gutter clicks
2363            }
2364            0 // Drag handler uses position 0 of the line
2365        } else {
2366            content_col.saturating_sub(gutter_width) as usize
2367        };
2368
2369        // Use cached view line mappings for accurate position lookup
2370        let visual_row = content_row as usize;
2371
2372        // Helper to get position from a line mapping at a given visual column
2373        let position_from_mapping =
2374            |line_mapping: &crate::app::types::ViewLineMapping, col: usize| -> usize {
2375                if col < line_mapping.visual_to_char.len() {
2376                    // Use O(1) lookup: visual column -> char index -> source byte
2377                    if let Some(byte_pos) = line_mapping.source_byte_at_visual_col(col) {
2378                        return byte_pos;
2379                    }
2380                    // Column maps to virtual/injected content - find nearest real position
2381                    for c in (0..col).rev() {
2382                        if let Some(byte_pos) = line_mapping.source_byte_at_visual_col(c) {
2383                            return byte_pos;
2384                        }
2385                    }
2386                    line_mapping.line_end_byte
2387                } else {
2388                    // Click is past end of visible content
2389                    // For empty lines (only a newline), return the line start position
2390                    // to keep cursor on this line rather than jumping to the next line
2391                    if line_mapping.visual_to_char.len() <= 1 {
2392                        // Empty or newline-only line - return first source byte if available
2393                        if let Some(Some(first_byte)) = line_mapping.char_source_bytes.first() {
2394                            return *first_byte;
2395                        }
2396                    }
2397                    line_mapping.line_end_byte
2398                }
2399            };
2400
2401        let position = cached_mappings
2402            .as_ref()
2403            .and_then(|mappings| {
2404                if let Some(line_mapping) = mappings.get(visual_row) {
2405                    // Click is on a visible line
2406                    Some(position_from_mapping(line_mapping, text_col))
2407                } else if !mappings.is_empty() {
2408                    // Click is below last visible line - use the last line at the clicked column
2409                    let last_mapping = mappings.last().unwrap();
2410                    Some(position_from_mapping(last_mapping, text_col))
2411                } else {
2412                    None
2413                }
2414            })
2415            .unwrap_or(fallback_position);
2416
2417        Some(position)
2418    }
2419
2420    pub(super) fn adjust_content_rect_for_compose(
2421        content_rect: ratatui::layout::Rect,
2422        compose_width: Option<u16>,
2423    ) -> ratatui::layout::Rect {
2424        if let Some(cw) = compose_width {
2425            let clamped = cw.min(content_rect.width).max(1);
2426            if clamped < content_rect.width {
2427                let pad_total = content_rect.width - clamped;
2428                let left_pad = pad_total / 2;
2429                ratatui::layout::Rect::new(
2430                    content_rect.x + left_pad,
2431                    content_rect.y,
2432                    clamped,
2433                    content_rect.height,
2434                )
2435            } else {
2436                content_rect
2437            }
2438        } else {
2439            content_rect
2440        }
2441    }
2442
2443    /// Check whether a gutter click at `target_position` should toggle a fold.
2444    /// Returns `Some(target_position)` (the byte to fold at) or `None`.
2445    fn fold_toggle_byte_from_position(
2446        state: &crate::state::EditorState,
2447        collapsed_header_bytes: &std::collections::BTreeMap<usize, Option<String>>,
2448        target_position: usize,
2449        content_col: u16,
2450        gutter_width: u16,
2451    ) -> Option<usize> {
2452        if content_col >= gutter_width {
2453            return None;
2454        }
2455
2456        use crate::view::folding::indent_folding;
2457        let line_start = indent_folding::find_line_start_byte(&state.buffer, target_position);
2458
2459        // Already collapsed → allow toggling (unfold)
2460        if collapsed_header_bytes.contains_key(&line_start) {
2461            return Some(target_position);
2462        }
2463
2464        // Check LSP folding ranges first (line-based comparison unavoidable)
2465        if !state.folding_ranges.is_empty() {
2466            let line = state.buffer.get_line_number(target_position);
2467            let has_lsp_fold = state.folding_ranges.iter().any(|range| {
2468                let start_line = range.start_line as usize;
2469                let end_line = range.end_line as usize;
2470                start_line == line && end_line > start_line
2471            });
2472            if has_lsp_fold {
2473                return Some(target_position);
2474            }
2475        }
2476
2477        // Fallback: indent-based foldable detection on bytes when LSP ranges are empty
2478        if state.folding_ranges.is_empty() {
2479            let tab_size = state.buffer_settings.tab_size;
2480            let max_scan = crate::config::INDENT_FOLD_INDICATOR_MAX_SCAN;
2481            let max_bytes = max_scan * state.buffer.estimated_line_length();
2482            if indent_folding::indent_fold_end_byte(&state.buffer, line_start, tab_size, max_bytes)
2483                .is_some()
2484            {
2485                return Some(target_position);
2486            }
2487        }
2488
2489        None
2490    }
2491
2492    pub(super) fn fold_toggle_line_at_screen_position(
2493        &self,
2494        col: u16,
2495        row: u16,
2496    ) -> Option<(BufferId, usize)> {
2497        for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
2498            &self.cached_layout.split_areas
2499        {
2500            if col < content_rect.x
2501                || col >= content_rect.x + content_rect.width
2502                || row < content_rect.y
2503                || row >= content_rect.y + content_rect.height
2504            {
2505                continue;
2506            }
2507
2508            if self.is_terminal_buffer(*buffer_id) || self.is_composite_buffer(*buffer_id) {
2509                continue;
2510            }
2511
2512            let (gutter_width, collapsed_header_bytes) = {
2513                let state = self.buffers.get(buffer_id)?;
2514                let headers = self
2515                    .split_view_states
2516                    .get(split_id)
2517                    .map(|vs| {
2518                        vs.folds
2519                            .collapsed_header_bytes(&state.buffer, &state.marker_list)
2520                    })
2521                    .unwrap_or_default();
2522                (state.margins.left_total_width() as u16, headers)
2523            };
2524
2525            let cached_mappings = self.cached_layout.view_line_mappings.get(split_id).cloned();
2526            let fallback = self
2527                .split_view_states
2528                .get(split_id)
2529                .map(|vs| vs.viewport.top_byte)
2530                .unwrap_or(0);
2531            let compose_width = self
2532                .split_view_states
2533                .get(split_id)
2534                .and_then(|vs| vs.compose_width);
2535
2536            let target_position = Self::screen_to_buffer_position(
2537                col,
2538                row,
2539                *content_rect,
2540                gutter_width,
2541                &cached_mappings,
2542                fallback,
2543                true,
2544                compose_width,
2545            )?;
2546
2547            let adjusted_rect = Self::adjust_content_rect_for_compose(*content_rect, compose_width);
2548            let content_col = col.saturating_sub(adjusted_rect.x);
2549            let state = self.buffers.get(buffer_id)?;
2550            if let Some(byte_pos) = Self::fold_toggle_byte_from_position(
2551                state,
2552                &collapsed_header_bytes,
2553                target_position,
2554                content_col,
2555                gutter_width,
2556            ) {
2557                return Some((*buffer_id, byte_pos));
2558            }
2559        }
2560
2561        None
2562    }
2563
2564    /// Handle click in editor content area
2565    pub(super) fn handle_editor_click(
2566        &mut self,
2567        col: u16,
2568        row: u16,
2569        split_id: crate::model::event::LeafId,
2570        buffer_id: BufferId,
2571        content_rect: ratatui::layout::Rect,
2572        modifiers: crossterm::event::KeyModifiers,
2573    ) -> AnyhowResult<()> {
2574        use crate::model::event::{CursorId, Event};
2575        use crossterm::event::KeyModifiers;
2576        // Build modifiers string for plugins
2577        let modifiers_str = if modifiers.contains(KeyModifiers::SHIFT) {
2578            "shift".to_string()
2579        } else {
2580            String::new()
2581        };
2582
2583        // Dispatch MouseClick hook to plugins
2584        // Plugins can handle clicks on their virtual buffers
2585        if self.plugin_manager.has_hook_handlers("mouse_click") {
2586            self.plugin_manager.run_hook(
2587                "mouse_click",
2588                HookArgs::MouseClick {
2589                    column: col,
2590                    row,
2591                    button: "left".to_string(),
2592                    modifiers: modifiers_str,
2593                    content_x: content_rect.x,
2594                    content_y: content_rect.y,
2595                },
2596            );
2597        }
2598
2599        // Focus this split (handles terminal mode exit, tab state, etc.)
2600        self.focus_split(split_id, buffer_id);
2601
2602        // Handle composite buffer clicks specially
2603        if self.is_composite_buffer(buffer_id) {
2604            return self.handle_composite_click(col, row, split_id, buffer_id, content_rect);
2605        }
2606
2607        // Ensure key context is Normal for non-terminal buffers
2608        // This handles the edge case where split/buffer don't change but we clicked from FileExplorer
2609        if !self.is_terminal_buffer(buffer_id) {
2610            self.key_context = crate::input::keybindings::KeyContext::Normal;
2611        }
2612
2613        // Get cached view line mappings for this split (before mutable borrow of buffers)
2614        let cached_mappings = self
2615            .cached_layout
2616            .view_line_mappings
2617            .get(&split_id)
2618            .cloned();
2619
2620        // Get fallback from SplitViewState viewport
2621        let fallback = self
2622            .split_view_states
2623            .get(&split_id)
2624            .map(|vs| vs.viewport.top_byte)
2625            .unwrap_or(0);
2626
2627        // Get compose width for this split (adjusts content rect for centered layout)
2628        let compose_width = self
2629            .split_view_states
2630            .get(&split_id)
2631            .and_then(|vs| vs.compose_width);
2632
2633        // Calculate clicked position in buffer
2634        let (toggle_fold_byte, onclick_action, target_position, cursor_snapshot) =
2635            if let Some(state) = self.buffers.get(&buffer_id) {
2636                let gutter_width = state.margins.left_total_width() as u16;
2637
2638                let Some(target_position) = Self::screen_to_buffer_position(
2639                    col,
2640                    row,
2641                    content_rect,
2642                    gutter_width,
2643                    &cached_mappings,
2644                    fallback,
2645                    true, // Allow gutter clicks - position cursor at start of line
2646                    compose_width,
2647                ) else {
2648                    return Ok(());
2649                };
2650
2651                // Toggle fold on gutter click if this line is foldable/collapsed
2652                let adjusted_rect =
2653                    Self::adjust_content_rect_for_compose(content_rect, compose_width);
2654                let content_col = col.saturating_sub(adjusted_rect.x);
2655                let collapsed_header_bytes = self
2656                    .split_view_states
2657                    .get(&split_id)
2658                    .map(|vs| {
2659                        vs.folds
2660                            .collapsed_header_bytes(&state.buffer, &state.marker_list)
2661                    })
2662                    .unwrap_or_default();
2663                let toggle_fold_byte = Self::fold_toggle_byte_from_position(
2664                    state,
2665                    &collapsed_header_bytes,
2666                    target_position,
2667                    content_col,
2668                    gutter_width,
2669                );
2670
2671                let cursor_snapshot = self
2672                    .split_view_states
2673                    .get(&split_id)
2674                    .map(|vs| {
2675                        let cursor = vs.cursors.primary();
2676                        (
2677                            vs.cursors.primary_id(),
2678                            cursor.position,
2679                            cursor.anchor,
2680                            cursor.sticky_column,
2681                            cursor.deselect_on_move,
2682                        )
2683                    })
2684                    .unwrap_or((CursorId(0), 0, None, 0, true));
2685
2686                // Check for onClick text property at this position
2687                // This enables clickable UI elements in virtual buffers
2688                let onclick_action = state
2689                    .text_properties
2690                    .get_at(target_position)
2691                    .iter()
2692                    .find_map(|prop| {
2693                        prop.get("onClick")
2694                            .and_then(|v| v.as_str())
2695                            .map(|s| s.to_string())
2696                    });
2697
2698                (
2699                    toggle_fold_byte,
2700                    onclick_action,
2701                    target_position,
2702                    cursor_snapshot,
2703                )
2704            } else {
2705                return Ok(());
2706            };
2707
2708        if toggle_fold_byte.is_some() {
2709            self.toggle_fold_at_byte(buffer_id, target_position);
2710            return Ok(());
2711        }
2712
2713        let (primary_cursor_id, old_position, old_anchor, old_sticky_column, deselect_on_move) =
2714            cursor_snapshot;
2715
2716        if let Some(action_name) = onclick_action {
2717            // Execute the action associated with this clickable element
2718            tracing::debug!(
2719                "onClick triggered at position {}: action={}",
2720                target_position,
2721                action_name
2722            );
2723            let empty_args = std::collections::HashMap::new();
2724            if let Some(action) = Action::from_str(&action_name, &empty_args) {
2725                return self.handle_action(action);
2726            }
2727            return Ok(());
2728        }
2729
2730        // Move cursor to clicked position (respect shift for selection)
2731        // Both modifiers supported since some terminals intercept shift+click.
2732        let extend_selection =
2733            modifiers.contains(KeyModifiers::SHIFT) || modifiers.contains(KeyModifiers::CONTROL);
2734        let new_anchor = if extend_selection {
2735            Some(old_anchor.unwrap_or(old_position))
2736        } else if deselect_on_move {
2737            None
2738        } else {
2739            old_anchor
2740        };
2741
2742        let new_sticky_column = self
2743            .buffers
2744            .get(&buffer_id)
2745            .and_then(|state| state.buffer.offset_to_position(target_position))
2746            .map(|pos| pos.column)
2747            .unwrap_or(0);
2748
2749        let event = Event::MoveCursor {
2750            cursor_id: primary_cursor_id,
2751            old_position,
2752            new_position: target_position,
2753            old_anchor,
2754            new_anchor,
2755            old_sticky_column,
2756            new_sticky_column,
2757        };
2758
2759        self.active_event_log_mut().append(event.clone());
2760        self.apply_event_to_active_buffer(&event);
2761        self.track_cursor_movement(&event);
2762
2763        // Start text selection drag for potential mouse drag
2764        self.mouse_state.dragging_text_selection = true;
2765        self.mouse_state.drag_selection_split = Some(split_id);
2766        self.mouse_state.drag_selection_anchor = Some(new_anchor.unwrap_or(target_position));
2767
2768        Ok(())
2769    }
2770
2771    /// Handle click in file explorer
2772    pub(super) fn handle_file_explorer_click(
2773        &mut self,
2774        col: u16,
2775        row: u16,
2776        explorer_area: ratatui::layout::Rect,
2777    ) -> AnyhowResult<()> {
2778        // Check if click is on the title bar (first row)
2779        if row == explorer_area.y {
2780            // Check if click is on close button (× at right side of title bar)
2781            // Close button is at position: explorer_area.x + explorer_area.width - 3 to -1
2782            let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
2783            if col >= close_button_x && col < explorer_area.x + explorer_area.width {
2784                self.toggle_file_explorer();
2785                return Ok(());
2786            }
2787        }
2788
2789        // Focus file explorer
2790        self.key_context = crate::input::keybindings::KeyContext::FileExplorer;
2791
2792        // Calculate which item was clicked (accounting for border and title)
2793        // The file explorer has a 1-line border at top and bottom
2794        let relative_row = row.saturating_sub(explorer_area.y + 1); // +1 for top border
2795
2796        if let Some(ref mut explorer) = self.file_explorer {
2797            let display_nodes = explorer.get_display_nodes();
2798            let scroll_offset = explorer.get_scroll_offset();
2799            let clicked_index = (relative_row as usize) + scroll_offset;
2800
2801            if clicked_index < display_nodes.len() {
2802                let (node_id, _indent) = display_nodes[clicked_index];
2803
2804                // Select this node
2805                explorer.set_selected(Some(node_id));
2806
2807                // Check if it's a file or directory
2808                let node = explorer.tree().get_node(node_id);
2809                if let Some(node) = node {
2810                    if node.is_dir() {
2811                        // Toggle expand/collapse using the existing method
2812                        self.file_explorer_toggle_expand();
2813                    } else if node.is_file() {
2814                        // Open the file but keep focus on file explorer (single click)
2815                        // Double-click or Enter will focus the editor
2816                        let path = node.entry.path.clone();
2817                        let name = node.entry.name.clone();
2818                        match self.open_file(&path) {
2819                            Ok(_) => {
2820                                self.set_status_message(
2821                                    rust_i18n::t!("explorer.opened_file", name = &name).to_string(),
2822                                );
2823                            }
2824                            Err(e) => {
2825                                // Check if this is a large file encoding confirmation error
2826                                if let Some(confirmation) = e.downcast_ref::<
2827                                    crate::model::buffer::LargeFileEncodingConfirmation,
2828                                >() {
2829                                    self.start_large_file_encoding_confirmation(confirmation);
2830                                } else {
2831                                    self.set_status_message(
2832                                        rust_i18n::t!("file.error_opening", error = e.to_string())
2833                                            .to_string(),
2834                                    );
2835                                }
2836                            }
2837                        }
2838                    }
2839                }
2840            }
2841        }
2842
2843        Ok(())
2844    }
2845
2846    /// Start the line ending selection prompt
2847    fn start_set_line_ending_prompt(&mut self) {
2848        use crate::model::buffer::LineEnding;
2849
2850        let current_line_ending = self.active_state().buffer.line_ending();
2851
2852        let options = [
2853            (LineEnding::LF, "LF", "Unix/Linux/Mac"),
2854            (LineEnding::CRLF, "CRLF", "Windows"),
2855            (LineEnding::CR, "CR", "Classic Mac"),
2856        ];
2857
2858        let current_index = options
2859            .iter()
2860            .position(|(le, _, _)| *le == current_line_ending)
2861            .unwrap_or(0);
2862
2863        let suggestions: Vec<crate::input::commands::Suggestion> = options
2864            .iter()
2865            .map(|(le, name, desc)| {
2866                let is_current = *le == current_line_ending;
2867                crate::input::commands::Suggestion {
2868                    text: format!("{} ({})", name, desc),
2869                    description: if is_current {
2870                        Some("current".to_string())
2871                    } else {
2872                        None
2873                    },
2874                    value: Some(name.to_string()),
2875                    disabled: false,
2876                    keybinding: None,
2877                    source: None,
2878                }
2879            })
2880            .collect();
2881
2882        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2883            "Line ending: ".to_string(),
2884            PromptType::SetLineEnding,
2885            suggestions,
2886        ));
2887
2888        if let Some(prompt) = self.prompt.as_mut() {
2889            if !prompt.suggestions.is_empty() {
2890                prompt.selected_suggestion = Some(current_index);
2891                let (_, name, desc) = options[current_index];
2892                prompt.input = format!("{} ({})", name, desc);
2893                prompt.cursor_pos = prompt.input.len();
2894            }
2895        }
2896    }
2897
2898    /// Start the encoding selection prompt
2899    fn start_set_encoding_prompt(&mut self) {
2900        use crate::model::buffer::Encoding;
2901
2902        let current_encoding = self.active_state().buffer.encoding();
2903
2904        let suggestions: Vec<crate::input::commands::Suggestion> = Encoding::all()
2905            .iter()
2906            .map(|enc| {
2907                let is_current = *enc == current_encoding;
2908                crate::input::commands::Suggestion {
2909                    text: format!("{} ({})", enc.display_name(), enc.description()),
2910                    description: if is_current {
2911                        Some("current".to_string())
2912                    } else {
2913                        None
2914                    },
2915                    value: Some(enc.display_name().to_string()),
2916                    disabled: false,
2917                    keybinding: None,
2918                    source: None,
2919                }
2920            })
2921            .collect();
2922
2923        let current_index = Encoding::all()
2924            .iter()
2925            .position(|enc| *enc == current_encoding)
2926            .unwrap_or(0);
2927
2928        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
2929            "Encoding: ".to_string(),
2930            PromptType::SetEncoding,
2931            suggestions,
2932        ));
2933
2934        if let Some(prompt) = self.prompt.as_mut() {
2935            if !prompt.suggestions.is_empty() {
2936                prompt.selected_suggestion = Some(current_index);
2937                let enc = Encoding::all()[current_index];
2938                prompt.input = format!("{} ({})", enc.display_name(), enc.description());
2939                prompt.cursor_pos = prompt.input.len();
2940                // Select all text so typing immediately replaces it
2941                prompt.selection_anchor = Some(0);
2942            }
2943        }
2944    }
2945
2946    /// Start the reload with encoding prompt
2947    ///
2948    /// Prompts user to select an encoding, then reloads the current file with that encoding.
2949    /// Requires the buffer to have no unsaved modifications.
2950    fn start_reload_with_encoding_prompt(&mut self) {
2951        use crate::model::buffer::Encoding;
2952
2953        // Check if buffer has a file path
2954        let has_file = self
2955            .buffers
2956            .get(&self.active_buffer())
2957            .and_then(|s| s.buffer.file_path())
2958            .is_some();
2959
2960        if !has_file {
2961            self.set_status_message("Cannot reload: buffer has no file".to_string());
2962            return;
2963        }
2964
2965        // Check for unsaved modifications
2966        let is_modified = self
2967            .buffers
2968            .get(&self.active_buffer())
2969            .map(|s| s.buffer.is_modified())
2970            .unwrap_or(false);
2971
2972        if is_modified {
2973            self.set_status_message(
2974                "Cannot reload: buffer has unsaved modifications (save first)".to_string(),
2975            );
2976            return;
2977        }
2978
2979        let current_encoding = self.active_state().buffer.encoding();
2980
2981        let suggestions: Vec<crate::input::commands::Suggestion> = Encoding::all()
2982            .iter()
2983            .map(|enc| {
2984                let is_current = *enc == current_encoding;
2985                crate::input::commands::Suggestion {
2986                    text: format!("{} ({})", enc.display_name(), enc.description()),
2987                    description: if is_current {
2988                        Some("current".to_string())
2989                    } else {
2990                        None
2991                    },
2992                    value: Some(enc.display_name().to_string()),
2993                    disabled: false,
2994                    keybinding: None,
2995                    source: None,
2996                }
2997            })
2998            .collect();
2999
3000        let current_index = Encoding::all()
3001            .iter()
3002            .position(|enc| *enc == current_encoding)
3003            .unwrap_or(0);
3004
3005        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3006            "Reload with encoding: ".to_string(),
3007            PromptType::ReloadWithEncoding,
3008            suggestions,
3009        ));
3010
3011        if let Some(prompt) = self.prompt.as_mut() {
3012            if !prompt.suggestions.is_empty() {
3013                prompt.selected_suggestion = Some(current_index);
3014                let enc = Encoding::all()[current_index];
3015                prompt.input = format!("{} ({})", enc.display_name(), enc.description());
3016                prompt.cursor_pos = prompt.input.len();
3017            }
3018        }
3019    }
3020
3021    /// Start the language selection prompt
3022    fn start_set_language_prompt(&mut self) {
3023        use crate::input::commands::CommandSource;
3024
3025        let current_language = self.active_state().language.clone();
3026
3027        // Build a reverse map from syntect display name -> (config key, source label)
3028        // so we can show extra columns in the language selector popup.
3029        let mut syntax_to_config: std::collections::HashMap<String, (String, &str)> =
3030            std::collections::HashMap::new();
3031        for (lang_id, lang_config) in &self.config.languages {
3032            if let Some(syntax) = self
3033                .grammar_registry
3034                .find_syntax_for_lang_config(lang_config)
3035            {
3036                syntax_to_config
3037                    .entry(syntax.name.clone())
3038                    .or_insert((lang_id.clone(), "config"));
3039            }
3040        }
3041
3042        // Build suggestions from all available syntect syntaxes + Plain Text option
3043        let mut suggestions: Vec<crate::input::commands::Suggestion> = vec![
3044            // Plain Text option (no syntax highlighting)
3045            crate::input::commands::Suggestion {
3046                text: "Plain Text".to_string(),
3047                description: if current_language == "text" || current_language == "Plain Text" {
3048                    Some("current".to_string())
3049                } else {
3050                    None
3051                },
3052                value: Some("Plain Text".to_string()),
3053                disabled: false,
3054                keybinding: Some("text".to_string()),
3055                source: Some(CommandSource::Builtin),
3056            },
3057        ];
3058
3059        // Entries: (display_name, config_key, source, value_for_selection)
3060        // display_name = syntect syntax name or config lang_id
3061        // config_key = the key from config.json languages section (if any)
3062        // source = "config" or "builtin"
3063        struct LangEntry {
3064            display_name: String,
3065            config_key: String,
3066            source: &'static str,
3067        }
3068
3069        let mut entries: Vec<LangEntry> = Vec::new();
3070
3071        // Add all available syntaxes from the grammar registry
3072        for syntax_name in self.grammar_registry.available_syntaxes() {
3073            if syntax_name == "Plain Text" {
3074                continue;
3075            }
3076            let (config_key, source) = syntax_to_config
3077                .get(syntax_name)
3078                .map(|(k, s)| (k.clone(), *s))
3079                .unwrap_or_else(|| (syntax_name.to_lowercase(), "builtin"));
3080            entries.push(LangEntry {
3081                display_name: syntax_name.to_string(),
3082                config_key,
3083                source,
3084            });
3085        }
3086
3087        // Add user-configured languages that don't have a matching syntect grammar
3088        let entry_names_lower: std::collections::HashSet<String> = entries
3089            .iter()
3090            .map(|e| e.display_name.to_lowercase())
3091            .collect();
3092        for (lang_id, lang_config) in &self.config.languages {
3093            let has_grammar = !lang_config.grammar.is_empty()
3094                && self
3095                    .grammar_registry
3096                    .find_syntax_by_name(&lang_config.grammar)
3097                    .is_some();
3098            if !has_grammar && !entry_names_lower.contains(&lang_id.to_lowercase()) {
3099                entries.push(LangEntry {
3100                    display_name: lang_id.clone(),
3101                    config_key: lang_id.clone(),
3102                    source: "config",
3103                });
3104            }
3105        }
3106
3107        // Sort alphabetically for easier navigation
3108        entries.sort_unstable_by(|a, b| {
3109            a.display_name
3110                .to_lowercase()
3111                .cmp(&b.display_name.to_lowercase())
3112        });
3113
3114        let mut current_index_found = None;
3115        for entry in &entries {
3116            let is_current =
3117                entry.config_key == current_language || entry.display_name == current_language;
3118            if is_current {
3119                current_index_found = Some(suggestions.len());
3120            }
3121
3122            let description = if is_current {
3123                format!("{} (current)", entry.config_key)
3124            } else {
3125                entry.config_key.clone()
3126            };
3127
3128            let source = if entry.source == "config" {
3129                Some(CommandSource::Plugin("config".to_string()))
3130            } else {
3131                Some(CommandSource::Builtin)
3132            };
3133
3134            suggestions.push(crate::input::commands::Suggestion {
3135                text: entry.display_name.clone(),
3136                description: Some(description),
3137                value: Some(entry.display_name.clone()),
3138                disabled: false,
3139                keybinding: None,
3140                source,
3141            });
3142        }
3143
3144        // Find current language index
3145        let current_index = current_index_found.unwrap_or(0);
3146
3147        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3148            "Language: ".to_string(),
3149            PromptType::SetLanguage,
3150            suggestions,
3151        ));
3152
3153        if let Some(prompt) = self.prompt.as_mut() {
3154            if !prompt.suggestions.is_empty() {
3155                prompt.selected_suggestion = Some(current_index);
3156                // Don't set input - keep it empty so typing filters the list
3157                // The selected suggestion shows the current language
3158            }
3159        }
3160    }
3161
3162    /// Start the theme selection prompt with available themes
3163    fn start_select_theme_prompt(&mut self) {
3164        let available_themes = self.theme_registry.list();
3165        let current_theme_name = &self.theme.name;
3166
3167        // Find the index of the current theme
3168        let current_index = available_themes
3169            .iter()
3170            .position(|info| info.name == *current_theme_name)
3171            .unwrap_or(0);
3172
3173        let suggestions: Vec<crate::input::commands::Suggestion> = available_themes
3174            .iter()
3175            .map(|info| {
3176                let is_current = info.name == *current_theme_name;
3177                let description = match (is_current, info.pack.is_empty()) {
3178                    (true, true) => Some("(current)".to_string()),
3179                    (true, false) => Some(format!("{} (current)", info.pack)),
3180                    (false, true) => None,
3181                    (false, false) => Some(info.pack.clone()),
3182                };
3183                crate::input::commands::Suggestion {
3184                    text: info.name.clone(),
3185                    description,
3186                    value: Some(info.name.clone()),
3187                    disabled: false,
3188                    keybinding: None,
3189                    source: None,
3190                }
3191            })
3192            .collect();
3193
3194        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3195            "Select theme: ".to_string(),
3196            PromptType::SelectTheme {
3197                original_theme: current_theme_name.clone(),
3198            },
3199            suggestions,
3200        ));
3201
3202        if let Some(prompt) = self.prompt.as_mut() {
3203            if !prompt.suggestions.is_empty() {
3204                prompt.selected_suggestion = Some(current_index);
3205                // Also set input to match selected theme
3206                prompt.input = current_theme_name.to_string();
3207                prompt.cursor_pos = prompt.input.len();
3208            }
3209        }
3210    }
3211
3212    /// Apply a theme by name and persist it to config
3213    pub(super) fn apply_theme(&mut self, theme_name: &str) {
3214        if !theme_name.is_empty() {
3215            if let Some(theme) = self.theme_registry.get_cloned(theme_name) {
3216                self.theme = theme;
3217
3218                // Set terminal cursor color to match theme
3219                self.theme.set_terminal_cursor_color();
3220
3221                // Re-apply all overlays so colors match the new theme
3222                // (diagnostic and semantic token overlays bake RGB at creation time).
3223                self.reapply_all_overlays();
3224
3225                // Update the config in memory using the normalized registry key,
3226                // not the JSON name field, so that the config value can be looked
3227                // up in the registry on restart (fixes #1001).
3228                let normalized = crate::view::theme::normalize_theme_name(theme_name);
3229                self.config.theme = normalized.into();
3230
3231                // Persist to config file
3232                self.save_theme_to_config();
3233
3234                self.set_status_message(
3235                    t!("view.theme_changed", theme = self.theme.name.clone()).to_string(),
3236                );
3237            } else {
3238                self.set_status_message(format!("Theme '{}' not found", theme_name));
3239            }
3240        }
3241    }
3242
3243    /// Re-apply all stored diagnostics and semantic tokens with the current
3244    /// theme colors. Both overlay types bake RGB values at creation time, so
3245    /// they must be rebuilt when the theme changes.
3246    fn reapply_all_overlays(&mut self) {
3247        // --- Diagnostics ---
3248        crate::services::lsp::diagnostics::invalidate_cache_all();
3249        let entries: Vec<(String, Vec<lsp_types::Diagnostic>)> = self
3250            .stored_diagnostics
3251            .iter()
3252            .map(|(uri, diags)| (uri.clone(), diags.clone()))
3253            .collect();
3254        for (uri, diagnostics) in entries {
3255            if let Some(buffer_id) = self.find_buffer_by_uri(&uri) {
3256                if let Some(state) = self.buffers.get_mut(&buffer_id) {
3257                    crate::services::lsp::diagnostics::apply_diagnostics_to_state_cached(
3258                        state,
3259                        &diagnostics,
3260                        &self.theme,
3261                    );
3262                }
3263            }
3264        }
3265
3266        // --- Semantic tokens ---
3267        let buffer_ids: Vec<_> = self.buffers.keys().cloned().collect();
3268        for buffer_id in buffer_ids {
3269            let tokens = self
3270                .buffers
3271                .get(&buffer_id)
3272                .and_then(|s| s.semantic_tokens.as_ref())
3273                .map(|store| store.tokens.clone());
3274            if let Some(tokens) = tokens {
3275                if let Some(state) = self.buffers.get_mut(&buffer_id) {
3276                    crate::services::lsp::semantic_tokens::apply_semantic_tokens_to_state(
3277                        state,
3278                        &tokens,
3279                        &self.theme,
3280                    );
3281                }
3282            }
3283        }
3284    }
3285
3286    /// Preview a theme by name (without persisting to config)
3287    /// Used for live preview when navigating theme selection
3288    pub(super) fn preview_theme(&mut self, theme_name: &str) {
3289        if !theme_name.is_empty() && theme_name != self.theme.name {
3290            if let Some(theme) = self.theme_registry.get_cloned(theme_name) {
3291                self.theme = theme;
3292                self.theme.set_terminal_cursor_color();
3293                self.reapply_all_overlays();
3294            }
3295        }
3296    }
3297
3298    /// Save the current theme setting to the user's config file
3299    fn save_theme_to_config(&mut self) {
3300        // Create the directory if it doesn't exist
3301        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3302            tracing::warn!("Failed to create config directory: {}", e);
3303            return;
3304        }
3305
3306        // Save the theme using explicit changes to avoid the issue where
3307        // changing to the default theme doesn't persist (because save_to_layer
3308        // computes delta vs defaults and sees no difference).
3309        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3310        let config_path = resolver.user_config_path();
3311        tracing::info!(
3312            "Saving theme '{}' to user config at {}",
3313            self.config.theme.0,
3314            config_path.display()
3315        );
3316
3317        let mut changes = std::collections::HashMap::new();
3318        changes.insert(
3319            "/theme".to_string(),
3320            serde_json::Value::String(self.config.theme.0.clone()),
3321        );
3322
3323        match resolver.save_changes_to_layer(
3324            &changes,
3325            &std::collections::HashSet::new(),
3326            ConfigLayer::User,
3327        ) {
3328            Ok(()) => {
3329                tracing::info!("Theme saved successfully to {}", config_path.display());
3330            }
3331            Err(e) => {
3332                tracing::warn!("Failed to save theme to config: {}", e);
3333            }
3334        }
3335    }
3336
3337    /// Start the keybinding map selection prompt with available maps
3338    fn start_select_keybinding_map_prompt(&mut self) {
3339        // Built-in keybinding maps
3340        let builtin_maps = vec!["default", "emacs", "vscode", "macos"];
3341
3342        // Collect user-defined keybinding maps from config
3343        let user_maps: Vec<&str> = self
3344            .config
3345            .keybinding_maps
3346            .keys()
3347            .map(|s| s.as_str())
3348            .collect();
3349
3350        // Combine built-in and user maps
3351        let mut all_maps: Vec<&str> = builtin_maps;
3352        for map in &user_maps {
3353            if !all_maps.contains(map) {
3354                all_maps.push(map);
3355            }
3356        }
3357
3358        let current_map = &self.config.active_keybinding_map;
3359
3360        // Find the index of the current keybinding map
3361        let current_index = all_maps
3362            .iter()
3363            .position(|name| *name == current_map)
3364            .unwrap_or(0);
3365
3366        let suggestions: Vec<crate::input::commands::Suggestion> = all_maps
3367            .iter()
3368            .map(|map_name| {
3369                let is_current = *map_name == current_map;
3370                crate::input::commands::Suggestion {
3371                    text: map_name.to_string(),
3372                    description: if is_current {
3373                        Some("(current)".to_string())
3374                    } else {
3375                        None
3376                    },
3377                    value: Some(map_name.to_string()),
3378                    disabled: false,
3379                    keybinding: None,
3380                    source: None,
3381                }
3382            })
3383            .collect();
3384
3385        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3386            "Select keybinding map: ".to_string(),
3387            PromptType::SelectKeybindingMap,
3388            suggestions,
3389        ));
3390
3391        if let Some(prompt) = self.prompt.as_mut() {
3392            if !prompt.suggestions.is_empty() {
3393                prompt.selected_suggestion = Some(current_index);
3394                // Also set input to match selected map
3395                prompt.input = current_map.to_string();
3396                prompt.cursor_pos = prompt.input.len();
3397            }
3398        }
3399    }
3400
3401    /// Apply a keybinding map by name and persist it to config
3402    pub(super) fn apply_keybinding_map(&mut self, map_name: &str) {
3403        if map_name.is_empty() {
3404            return;
3405        }
3406
3407        // Check if the map exists (either built-in or user-defined)
3408        let is_builtin = matches!(map_name, "default" | "emacs" | "vscode" | "macos");
3409        let is_user_defined = self.config.keybinding_maps.contains_key(map_name);
3410
3411        if is_builtin || is_user_defined {
3412            // Update the active keybinding map in config
3413            self.config.active_keybinding_map = map_name.to_string().into();
3414
3415            // Reload the keybinding resolver with the new map
3416            self.keybindings = crate::input::keybindings::KeybindingResolver::new(&self.config);
3417
3418            // Persist to config file
3419            self.save_keybinding_map_to_config();
3420
3421            self.set_status_message(t!("view.keybindings_switched", map = map_name).to_string());
3422        } else {
3423            self.set_status_message(t!("view.keybindings_unknown", map = map_name).to_string());
3424        }
3425    }
3426
3427    /// Save the current keybinding map setting to the user's config file
3428    fn save_keybinding_map_to_config(&mut self) {
3429        // Create the directory if it doesn't exist
3430        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3431            tracing::warn!("Failed to create config directory: {}", e);
3432            return;
3433        }
3434
3435        // Save the config using the resolver
3436        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3437        if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
3438            tracing::warn!("Failed to save keybinding map to config: {}", e);
3439        }
3440    }
3441
3442    /// Start the cursor style selection prompt
3443    fn start_select_cursor_style_prompt(&mut self) {
3444        use crate::config::CursorStyle;
3445
3446        let current_style = self.config.editor.cursor_style;
3447
3448        // Build suggestions from available cursor styles
3449        let suggestions: Vec<crate::input::commands::Suggestion> = CursorStyle::OPTIONS
3450            .iter()
3451            .zip(CursorStyle::DESCRIPTIONS.iter())
3452            .map(|(style_name, description)| {
3453                let is_current = *style_name == current_style.as_str();
3454                crate::input::commands::Suggestion {
3455                    text: description.to_string(),
3456                    description: if is_current {
3457                        Some("(current)".to_string())
3458                    } else {
3459                        None
3460                    },
3461                    value: Some(style_name.to_string()),
3462                    disabled: false,
3463                    keybinding: None,
3464                    source: None,
3465                }
3466            })
3467            .collect();
3468
3469        // Find the index of the current cursor style
3470        let current_index = CursorStyle::OPTIONS
3471            .iter()
3472            .position(|s| *s == current_style.as_str())
3473            .unwrap_or(0);
3474
3475        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3476            "Select cursor style: ".to_string(),
3477            PromptType::SelectCursorStyle,
3478            suggestions,
3479        ));
3480
3481        if let Some(prompt) = self.prompt.as_mut() {
3482            if !prompt.suggestions.is_empty() {
3483                prompt.selected_suggestion = Some(current_index);
3484                prompt.input = CursorStyle::DESCRIPTIONS[current_index].to_string();
3485                prompt.cursor_pos = prompt.input.len();
3486            }
3487        }
3488    }
3489
3490    /// Apply a cursor style and persist it to config
3491    pub(super) fn apply_cursor_style(&mut self, style_name: &str) {
3492        use crate::config::CursorStyle;
3493
3494        if let Some(style) = CursorStyle::parse(style_name) {
3495            // Update the config in memory
3496            self.config.editor.cursor_style = style;
3497
3498            // Apply the cursor style to the terminal
3499            if self.session_mode {
3500                // In session mode, queue the escape sequence to be sent to the client
3501                self.queue_escape_sequences(style.to_escape_sequence());
3502            } else {
3503                // In normal mode, write directly to stdout
3504                use std::io::stdout;
3505                // Best-effort cursor style change to stdout.
3506                #[allow(clippy::let_underscore_must_use)]
3507                let _ = crossterm::execute!(stdout(), style.to_crossterm_style());
3508            }
3509
3510            // Persist to config file
3511            self.save_cursor_style_to_config();
3512
3513            // Find the description for the status message
3514            let description = CursorStyle::OPTIONS
3515                .iter()
3516                .zip(CursorStyle::DESCRIPTIONS.iter())
3517                .find(|(name, _)| **name == style_name)
3518                .map(|(_, desc)| *desc)
3519                .unwrap_or(style_name);
3520
3521            self.set_status_message(
3522                t!("view.cursor_style_changed", style = description).to_string(),
3523            );
3524        }
3525    }
3526
3527    /// Start the remove ruler prompt with current rulers as suggestions
3528    fn start_remove_ruler_prompt(&mut self) {
3529        let active_split = self.split_manager.active_split();
3530        let rulers = self
3531            .split_view_states
3532            .get(&active_split)
3533            .map(|vs| vs.rulers.clone())
3534            .unwrap_or_default();
3535
3536        if rulers.is_empty() {
3537            self.set_status_message(t!("rulers.none_configured").to_string());
3538            return;
3539        }
3540
3541        let suggestions: Vec<crate::input::commands::Suggestion> = rulers
3542            .iter()
3543            .map(|&col| crate::input::commands::Suggestion {
3544                text: format!("Column {}", col),
3545                description: None,
3546                value: Some(col.to_string()),
3547                disabled: false,
3548                keybinding: None,
3549                source: None,
3550            })
3551            .collect();
3552
3553        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3554            t!("rulers.remove_prompt").to_string(),
3555            PromptType::RemoveRuler,
3556            suggestions,
3557        ));
3558    }
3559
3560    /// Save the current cursor style setting to the user's config file
3561    fn save_cursor_style_to_config(&mut self) {
3562        // Create the directory if it doesn't exist
3563        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3564            tracing::warn!("Failed to create config directory: {}", e);
3565            return;
3566        }
3567
3568        // Save the config using the resolver
3569        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3570        if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
3571            tracing::warn!("Failed to save cursor style to config: {}", e);
3572        }
3573    }
3574
3575    /// Start the locale selection prompt with available locales
3576    fn start_select_locale_prompt(&mut self) {
3577        let available_locales = crate::i18n::available_locales();
3578        let current_locale = crate::i18n::current_locale();
3579
3580        // Find the index of the current locale
3581        let current_index = available_locales
3582            .iter()
3583            .position(|name| *name == current_locale)
3584            .unwrap_or(0);
3585
3586        let suggestions: Vec<crate::input::commands::Suggestion> = available_locales
3587            .iter()
3588            .map(|locale_name| {
3589                let is_current = *locale_name == current_locale;
3590                let description = if let Some((english_name, native_name)) =
3591                    crate::i18n::locale_display_name(locale_name)
3592                {
3593                    if english_name == native_name {
3594                        // Same name (e.g., English/English)
3595                        if is_current {
3596                            format!("{} (current)", english_name)
3597                        } else {
3598                            english_name.to_string()
3599                        }
3600                    } else {
3601                        // Different names (e.g., German/Deutsch)
3602                        if is_current {
3603                            format!("{} / {} (current)", english_name, native_name)
3604                        } else {
3605                            format!("{} / {}", english_name, native_name)
3606                        }
3607                    }
3608                } else {
3609                    // Unknown locale
3610                    if is_current {
3611                        "(current)".to_string()
3612                    } else {
3613                        String::new()
3614                    }
3615                };
3616                crate::input::commands::Suggestion {
3617                    text: locale_name.to_string(),
3618                    description: if description.is_empty() {
3619                        None
3620                    } else {
3621                        Some(description)
3622                    },
3623                    value: Some(locale_name.to_string()),
3624                    disabled: false,
3625                    keybinding: None,
3626                    source: None,
3627                }
3628            })
3629            .collect();
3630
3631        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3632            t!("locale.select_prompt").to_string(),
3633            PromptType::SelectLocale,
3634            suggestions,
3635        ));
3636
3637        if let Some(prompt) = self.prompt.as_mut() {
3638            if !prompt.suggestions.is_empty() {
3639                prompt.selected_suggestion = Some(current_index);
3640                // Start with empty input to show all options initially
3641                prompt.input = String::new();
3642                prompt.cursor_pos = 0;
3643            }
3644        }
3645    }
3646
3647    /// Apply a locale and persist it to config
3648    pub(super) fn apply_locale(&mut self, locale_name: &str) {
3649        if !locale_name.is_empty() {
3650            // Update the locale at runtime
3651            crate::i18n::set_locale(locale_name);
3652
3653            // Update the config in memory
3654            self.config.locale = crate::config::LocaleName(Some(locale_name.to_string()));
3655
3656            // Regenerate menus with the new locale
3657            self.menus = crate::config::MenuConfig::translated();
3658
3659            // Refresh command palette commands with new locale
3660            if let Ok(mut registry) = self.command_registry.write() {
3661                registry.refresh_builtin_commands();
3662            }
3663
3664            // Persist to config file
3665            self.save_locale_to_config();
3666
3667            self.set_status_message(t!("locale.changed", locale_name = locale_name).to_string());
3668        }
3669    }
3670
3671    /// Save the current locale setting to the user's config file
3672    fn save_locale_to_config(&mut self) {
3673        // Create the directory if it doesn't exist
3674        if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
3675            tracing::warn!("Failed to create config directory: {}", e);
3676            return;
3677        }
3678
3679        // Save the config using the resolver
3680        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
3681        if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
3682            tracing::warn!("Failed to save locale to config: {}", e);
3683        }
3684    }
3685
3686    /// Switch to the previously active tab in the current split
3687    fn switch_to_previous_tab(&mut self) {
3688        let active_split = self.split_manager.active_split();
3689        let previous_buffer = self
3690            .split_view_states
3691            .get(&active_split)
3692            .and_then(|vs| vs.previous_buffer());
3693
3694        if let Some(prev_id) = previous_buffer {
3695            // Verify the buffer is still open in this split
3696            let is_valid = self
3697                .split_view_states
3698                .get(&active_split)
3699                .is_some_and(|vs| vs.open_buffers.contains(&prev_id));
3700
3701            if is_valid && prev_id != self.active_buffer() {
3702                // Save current position before switching
3703                self.position_history.commit_pending_movement();
3704
3705                let cursors = self.active_cursors();
3706                let position = cursors.primary().position;
3707                let anchor = cursors.primary().anchor;
3708                self.position_history
3709                    .record_movement(self.active_buffer(), position, anchor);
3710                self.position_history.commit_pending_movement();
3711
3712                self.set_active_buffer(prev_id);
3713            } else if !is_valid {
3714                self.set_status_message(t!("status.previous_tab_closed").to_string());
3715            }
3716        } else {
3717            self.set_status_message(t!("status.no_previous_tab").to_string());
3718        }
3719    }
3720
3721    /// Start the switch-to-tab-by-name prompt with suggestions from open buffers
3722    fn start_switch_to_tab_prompt(&mut self) {
3723        let active_split = self.split_manager.active_split();
3724        let open_buffers = if let Some(view_state) = self.split_view_states.get(&active_split) {
3725            view_state.open_buffers.clone()
3726        } else {
3727            return;
3728        };
3729
3730        if open_buffers.is_empty() {
3731            self.set_status_message(t!("status.no_tabs_in_split").to_string());
3732            return;
3733        }
3734
3735        // Find the current buffer's index
3736        let current_index = open_buffers
3737            .iter()
3738            .position(|&id| id == self.active_buffer())
3739            .unwrap_or(0);
3740
3741        let suggestions: Vec<crate::input::commands::Suggestion> = open_buffers
3742            .iter()
3743            .map(|&buffer_id| {
3744                let display_name = self
3745                    .buffer_metadata
3746                    .get(&buffer_id)
3747                    .map(|m| m.display_name.clone())
3748                    .unwrap_or_else(|| format!("Buffer {:?}", buffer_id));
3749
3750                let is_current = buffer_id == self.active_buffer();
3751                let is_modified = self
3752                    .buffers
3753                    .get(&buffer_id)
3754                    .is_some_and(|b| b.buffer.is_modified());
3755
3756                let description = match (is_current, is_modified) {
3757                    (true, true) => Some("(current, modified)".to_string()),
3758                    (true, false) => Some("(current)".to_string()),
3759                    (false, true) => Some("(modified)".to_string()),
3760                    (false, false) => None,
3761                };
3762
3763                crate::input::commands::Suggestion {
3764                    text: display_name,
3765                    description,
3766                    value: Some(buffer_id.0.to_string()),
3767                    disabled: false,
3768                    keybinding: None,
3769                    source: None,
3770                }
3771            })
3772            .collect();
3773
3774        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
3775            "Switch to tab: ".to_string(),
3776            PromptType::SwitchToTab,
3777            suggestions,
3778        ));
3779
3780        if let Some(prompt) = self.prompt.as_mut() {
3781            if !prompt.suggestions.is_empty() {
3782                prompt.selected_suggestion = Some(current_index);
3783            }
3784        }
3785    }
3786
3787    /// Switch to a tab by its BufferId
3788    pub(crate) fn switch_to_tab(&mut self, buffer_id: BufferId) {
3789        // Verify the buffer exists and is open in the current split
3790        let active_split = self.split_manager.active_split();
3791        let is_valid = self
3792            .split_view_states
3793            .get(&active_split)
3794            .is_some_and(|vs| vs.open_buffers.contains(&buffer_id));
3795
3796        if !is_valid {
3797            self.set_status_message(t!("status.tab_not_found").to_string());
3798            return;
3799        }
3800
3801        if buffer_id != self.active_buffer() {
3802            // Save current position before switching
3803            self.position_history.commit_pending_movement();
3804
3805            let cursors = self.active_cursors();
3806            let position = cursors.primary().position;
3807            let anchor = cursors.primary().anchor;
3808            self.position_history
3809                .record_movement(self.active_buffer(), position, anchor);
3810            self.position_history.commit_pending_movement();
3811
3812            self.set_active_buffer(buffer_id);
3813        }
3814    }
3815
3816    /// Handle character insertion in prompt mode.
3817    fn handle_insert_char_prompt(&mut self, c: char) -> AnyhowResult<()> {
3818        // Check if this is the query-replace confirmation prompt
3819        if let Some(ref prompt) = self.prompt {
3820            if prompt.prompt_type == PromptType::QueryReplaceConfirm {
3821                return self.handle_interactive_replace_key(c);
3822            }
3823        }
3824
3825        // Reset history navigation when user starts typing
3826        // This allows them to press Up to get back to history items
3827        // Reset history navigation when typing in a prompt
3828        if let Some(ref prompt) = self.prompt {
3829            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
3830                if let Some(history) = self.prompt_histories.get_mut(&key) {
3831                    history.reset_navigation();
3832                }
3833            }
3834        }
3835
3836        if let Some(prompt) = self.prompt_mut() {
3837            // Use insert_str to properly handle selection deletion
3838            let s = c.to_string();
3839            prompt.insert_str(&s);
3840        }
3841        self.update_prompt_suggestions();
3842        Ok(())
3843    }
3844
3845    /// Handle character insertion in normal editor mode.
3846    fn handle_insert_char_editor(&mut self, c: char) -> AnyhowResult<()> {
3847        // Check if editing is disabled (show_cursors = false)
3848        if self.is_editing_disabled() {
3849            self.set_status_message(t!("buffer.editing_disabled").to_string());
3850            return Ok(());
3851        }
3852
3853        // Cancel any pending LSP requests since the text is changing
3854        self.cancel_pending_lsp_requests();
3855
3856        if let Some(events) = self.action_to_events(Action::InsertChar(c)) {
3857            if events.len() > 1 {
3858                // Multi-cursor: use optimized bulk edit (O(n) instead of O(n²))
3859                let description = format!("Insert '{}'", c);
3860                if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description.clone())
3861                {
3862                    self.active_event_log_mut().append(bulk_edit);
3863                }
3864            } else {
3865                // Single cursor - apply normally
3866                for event in events {
3867                    self.active_event_log_mut().append(event.clone());
3868                    self.apply_event_to_active_buffer(&event);
3869                }
3870            }
3871        }
3872
3873        // Auto-trigger signature help on '(' and ','
3874        if c == '(' || c == ',' {
3875            self.request_signature_help();
3876        }
3877
3878        // Auto-trigger completion on trigger characters
3879        self.maybe_trigger_completion(c);
3880
3881        Ok(())
3882    }
3883
3884    /// Apply an action by converting it to events.
3885    ///
3886    /// This is the catch-all handler for actions that can be converted to buffer events
3887    /// (cursor movements, text edits, etc.). It handles batching for multi-cursor,
3888    /// position history tracking, and editing permission checks.
3889    fn apply_action_as_events(&mut self, action: Action) -> AnyhowResult<()> {
3890        // Check if active buffer is a composite buffer - handle scroll/movement specially
3891        let buffer_id = self.active_buffer();
3892        if self.is_composite_buffer(buffer_id) {
3893            if let Some(_handled) = self.handle_composite_action(buffer_id, &action) {
3894                return Ok(());
3895            }
3896        }
3897
3898        // Get description before moving action
3899        let action_description = format!("{:?}", action);
3900
3901        // Check if this is an editing action and editing is disabled
3902        let is_editing_action = matches!(
3903            action,
3904            Action::InsertNewline
3905                | Action::InsertTab
3906                | Action::DeleteForward
3907                | Action::DeleteWordBackward
3908                | Action::DeleteWordForward
3909                | Action::DeleteLine
3910                | Action::DuplicateLine
3911                | Action::MoveLineUp
3912                | Action::MoveLineDown
3913                | Action::DedentSelection
3914                | Action::ToggleComment
3915        );
3916
3917        if is_editing_action && self.is_editing_disabled() {
3918            self.set_status_message(t!("buffer.editing_disabled").to_string());
3919            return Ok(());
3920        }
3921
3922        if let Some(events) = self.action_to_events(action) {
3923            if events.len() > 1 {
3924                // Check if this batch contains buffer modifications
3925                let has_buffer_mods = events
3926                    .iter()
3927                    .any(|e| matches!(e, Event::Insert { .. } | Event::Delete { .. }));
3928
3929                if has_buffer_mods {
3930                    // Multi-cursor buffer edit: use optimized bulk edit (O(n) instead of O(n²))
3931                    if let Some(bulk_edit) =
3932                        self.apply_events_as_bulk_edit(events.clone(), action_description)
3933                    {
3934                        self.active_event_log_mut().append(bulk_edit);
3935                    }
3936                } else {
3937                    // Multi-cursor non-buffer operation: use Batch for atomic undo
3938                    let batch = Event::Batch {
3939                        events: events.clone(),
3940                        description: action_description,
3941                    };
3942                    self.active_event_log_mut().append(batch.clone());
3943                    self.apply_event_to_active_buffer(&batch);
3944                }
3945
3946                // Track position history for all events
3947                for event in &events {
3948                    self.track_cursor_movement(event);
3949                }
3950            } else {
3951                // Single cursor - apply normally
3952                for event in events {
3953                    self.log_and_apply_event(&event);
3954                    self.track_cursor_movement(&event);
3955                }
3956            }
3957        }
3958
3959        Ok(())
3960    }
3961
3962    /// Track cursor movement in position history if applicable.
3963    pub(super) fn track_cursor_movement(&mut self, event: &Event) {
3964        if self.in_navigation {
3965            return;
3966        }
3967
3968        if let Event::MoveCursor {
3969            new_position,
3970            new_anchor,
3971            ..
3972        } = event
3973        {
3974            self.position_history
3975                .record_movement(self.active_buffer(), *new_position, *new_anchor);
3976        }
3977    }
3978}