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