Skip to main content

fresh/app/
input.rs

1use super::*;
2use anyhow::Result as AnyhowResult;
3use rust_i18n::t;
4
5/// Convert a crossterm `KeyEvent` into the `KeyEventPayload` shape
6/// delivered to plugin `editor.getNextKey()` callers.
7///
8/// `key` matches the naming used by `defineMode` bindings:
9///   - named keys are lowercase (`"escape"`, `"enter"`, `"tab"`,
10///     `"space"`, `"backspace"`, arrows, `"f1"`–`"f12"`, …)
11///   - printable characters are returned as-is (`"a"`, `"!"`, `" "`)
12///   - unsupported / unknown keys yield an empty `key` string
13fn key_event_to_payload(ev: &crossterm::event::KeyEvent) -> fresh_core::api::KeyEventPayload {
14    use crossterm::event::{KeyCode, KeyModifiers};
15    let key = match ev.code {
16        KeyCode::Char(c) => c.to_string(),
17        KeyCode::Esc => "escape".to_string(),
18        KeyCode::Enter => "enter".to_string(),
19        KeyCode::Tab => "tab".to_string(),
20        KeyCode::BackTab => "backtab".to_string(),
21        KeyCode::Backspace => "backspace".to_string(),
22        KeyCode::Delete => "delete".to_string(),
23        KeyCode::Left => "left".to_string(),
24        KeyCode::Right => "right".to_string(),
25        KeyCode::Up => "up".to_string(),
26        KeyCode::Down => "down".to_string(),
27        KeyCode::Home => "home".to_string(),
28        KeyCode::End => "end".to_string(),
29        KeyCode::PageUp => "pageup".to_string(),
30        KeyCode::PageDown => "pagedown".to_string(),
31        KeyCode::Insert => "insert".to_string(),
32        KeyCode::F(n) => format!("f{}", n),
33        _ => String::new(),
34    };
35    fresh_core::api::KeyEventPayload {
36        key,
37        ctrl: ev.modifiers.contains(KeyModifiers::CONTROL),
38        alt: ev.modifiers.contains(KeyModifiers::ALT),
39        shift: ev.modifiers.contains(KeyModifiers::SHIFT),
40        meta: ev.modifiers.contains(KeyModifiers::SUPER),
41    }
42}
43
44impl Editor {
45    /// If a plugin is awaiting the next keypress (via
46    /// `editor.getNextKey()`), resolve the front-most pending
47    /// callback with this key and return `true` so the caller can
48    /// short-circuit further dispatch. The key is consumed by the
49    /// resolution; mode bindings and editor actions do not see it.
50    ///
51    /// If no callback is pending but the plugin has declared key
52    /// capture active (`editor.beginKeyCapture()`), buffer the key
53    /// instead of dispatching it. The next `AwaitNextKey` will pop
54    /// from the buffer immediately. This closes the race between
55    /// fast typing/paste and the plugin re-arming `getNextKey`
56    /// between iterations.
57    fn try_resolve_next_key_callback(&mut self, key_event: &crossterm::event::KeyEvent) -> bool {
58        let payload = key_event_to_payload(key_event);
59        if let Some(callback_id) = self.pending_next_key_callbacks.pop_front() {
60            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
61            self.plugin_manager.resolve_callback(callback_id, json);
62            return true;
63        }
64        if self.key_capture_active {
65            self.pending_key_capture_buffer.push_back(payload);
66            return true;
67        }
68        false
69    }
70}
71
72impl Editor {
73    /// Whether editor-pane popups (LSP completion, hover, signature help,
74    /// global plugin popups, …) should intercept keyboard input.
75    ///
76    /// Returns `false` when:
77    ///   - the user has focus on the file explorer pane (popups belong
78    ///     to the editor pane, and the explorer must own its own
79    ///     keystrokes), or
80    ///   - the topmost visible popup is unfocused (LSP popups appear
81    ///     unfocused so they don't silently swallow the next keystroke;
82    ///     the user grabs focus explicitly with `popup_focus`,
83    ///     default `Alt+T`).
84    ///
85    /// Buffer-switch handlers (e.g. `open_file_preview`) clear stale
86    /// popups so a popup tied to the previous preview doesn't follow the
87    /// user across buffers.
88    ///
89    /// Single source of truth for both `get_key_context` (binding resolution)
90    /// and `dispatch_modal_input` (handler routing) so the two cannot drift.
91    pub(crate) fn popups_capture_keys(&self) -> bool {
92        use crate::input::keybindings::KeyContext;
93        if matches!(self.key_context, KeyContext::FileExplorer) {
94            return false;
95        }
96        self.topmost_popup_focused()
97    }
98
99    /// Whether the topmost visible popup (global stack first, then the
100    /// active buffer's stack) has been marked focused. Returns `false`
101    /// when no popup is visible — the caller is responsible for
102    /// short-circuiting that case.
103    pub(crate) fn topmost_popup_focused(&self) -> bool {
104        if let Some(popup) = self.global_popups.top() {
105            return popup.focused;
106        }
107        if let Some(popup) = self.active_state().popups.top() {
108            return popup.focused;
109        }
110        // No popup → no capture. Returning `false` here is safe because
111        // every caller gates on visibility before reaching this path.
112        false
113    }
114
115    /// When an *unfocused* popup is on screen, resolve the key event
116    /// against `KeyContext::Popup`/`Global` so the user's bound
117    /// `popup_cancel` (default Esc) and `popup_focus` (default Alt+T)
118    /// keys still take effect even though the popup isn't claiming the
119    /// keyboard. Without this, dismissing an LSP auto-prompt with Esc
120    /// would silently fall through to the buffer.
121    ///
122    /// Returns `None` for any other action so type-to-filter, cursor
123    /// motion, etc. continue to drive the buffer.
124    pub(crate) fn resolve_unfocused_popup_action(
125        &self,
126        event: &crossterm::event::KeyEvent,
127    ) -> Option<crate::input::keybindings::Action> {
128        use crate::input::keybindings::{Action, KeyContext};
129
130        let popup_visible =
131            self.global_popups.is_visible() || self.active_state().popups.is_visible();
132        if !popup_visible || self.topmost_popup_focused() {
133            return None;
134        }
135
136        // Higher-priority modal contexts (Settings, Menu, Prompt) own the
137        // keyboard regardless of whether a buffer popup happens to be
138        // visible underneath. Skip the unfocused-popup interception so
139        // pressing Esc in a settings dialog still closes the dialog
140        // rather than reaching past it to dismiss a stale popup.
141        if self.settings_state.as_ref().is_some_and(|s| s.visible)
142            || self.menu_state.active_menu.is_some()
143            || self.is_prompting()
144        {
145            return None;
146        }
147
148        let kb = self.keybindings.read().ok()?;
149
150        // `popup_focus` lives in the Normal/FileExplorer context defaults
151        // (not Global) so a user's own binding for the same key in those
152        // contexts wins at the same precedence level. If the resolution
153        // here returns anything other than `PopupFocus`, it's the user's
154        // override — let the normal dispatcher handle it. Don't claim
155        // `popup_cancel` from Normal because Normal's default `Esc`
156        // resolves to `remove_secondary_cursors`, which would shadow the
157        // popup-dismiss intent here.
158        let popup_focus_match = matches!(
159            kb.resolve_in_context_only(event, self.key_context.clone()),
160            Some(Action::PopupFocus),
161        );
162        if popup_focus_match {
163            return Some(Action::PopupFocus);
164        }
165
166        // Fall back to the Popup context for `popup_cancel`. Esc
167        // (the default `popup_cancel` binding) should still dismiss
168        // an unfocused popup even though the popup itself isn't
169        // claiming the keyboard — that matches every other popup-
170        // dismissal affordance in the editor.
171        let resolved_popup = kb.resolve_in_context_only(event, KeyContext::Popup);
172        match resolved_popup {
173            Some(action @ (Action::PopupCancel | Action::PopupFocus)) => Some(action),
174            _ => None,
175        }
176    }
177
178    /// Resolve a key event against `KeyContext::Completion` when the topmost
179    /// visible popup is a completion popup. Only `CompletionAccept` and
180    /// `CompletionDismiss` are recognised here — every other key falls
181    /// through to the popup's own handler so type-to-filter, navigation, and
182    /// the "any other key dismisses + passthrough" behaviours stay intact.
183    pub(crate) fn resolve_completion_popup_action(
184        &self,
185        event: &crossterm::event::KeyEvent,
186    ) -> Option<crate::input::keybindings::Action> {
187        use crate::input::keybindings::{Action, KeyContext};
188        use crate::view::popup::PopupKind;
189
190        let topmost_kind = if self.global_popups.is_visible() {
191            self.global_popups.top().map(|p| p.kind)
192        } else if self.active_state().popups.is_visible() {
193            self.active_state().popups.top().map(|p| p.kind)
194        } else {
195            None
196        };
197
198        if topmost_kind != Some(PopupKind::Completion) {
199            return None;
200        }
201
202        match self
203            .keybindings
204            .read()
205            .unwrap()
206            .resolve_in_context_only(event, KeyContext::Completion)
207        {
208            Some(action @ (Action::CompletionAccept | Action::CompletionDismiss)) => Some(action),
209            _ => None,
210        }
211    }
212
213    /// Determine the current keybinding context based on UI state
214    pub fn get_key_context(&self) -> crate::input::keybindings::KeyContext {
215        use crate::input::keybindings::KeyContext;
216
217        // Priority order: Settings > Menu > Prompt > Popup (only when
218        // editor-pane focused) > CompositeBuffer > Current context
219        // (FileExplorer or Normal).
220        if self.settings_state.as_ref().is_some_and(|s| s.visible) {
221            KeyContext::Settings
222        } else if self.menu_state.active_menu.is_some() {
223            KeyContext::Menu
224        } else if self.is_prompting() {
225            KeyContext::Prompt
226        } else if self.popups_capture_keys()
227            && (self.global_popups.is_visible() || self.active_state().popups.is_visible())
228        {
229            KeyContext::Popup
230        } else if self.is_composite_buffer(self.active_buffer()) {
231            KeyContext::CompositeBuffer
232        } else {
233            // Use the current context (can be FileExplorer or Normal)
234            self.key_context.clone()
235        }
236    }
237
238    /// Handle a key event and return whether it was handled
239    /// This is the central key handling logic used by both main.rs and tests
240    pub fn handle_key(
241        &mut self,
242        code: crossterm::event::KeyCode,
243        modifiers: crossterm::event::KeyModifiers,
244    ) -> AnyhowResult<()> {
245        use crate::input::keybindings::Action;
246
247        let _t_total = std::time::Instant::now();
248
249        tracing::trace!(
250            "Editor.handle_key: code={:?}, modifiers={:?}",
251            code,
252            modifiers
253        );
254
255        // Create key event for dispatch methods
256        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
257
258        // Event debug dialog intercepts ALL key events before any other processing.
259        // This must be checked here (not just in main.rs/gui) so it works in
260        // client/server mode where handle_key is called directly.
261        if self.is_event_debug_active() {
262            self.handle_event_debug_input(&key_event);
263            return Ok(());
264        }
265
266        // Try terminal input dispatch first (handles terminal mode and re-entry)
267        if self.dispatch_terminal_input(&key_event).is_some() {
268            return Ok(());
269        }
270
271        // If a plugin is awaiting the next keypress (`editor.getNextKey()`),
272        // hand this key to the front-most pending callback and consume it.
273        // This must run before any other dispatch so the awaiting plugin —
274        // typically running a short input loop (flash labels, vi
275        // find-char/replace-char) — can drive its own state machine
276        // without binding every printable key in `defineMode`.
277        if self.try_resolve_next_key_callback(&key_event) {
278            return Ok(());
279        }
280
281        // Clear skip_ensure_visible flag so cursor becomes visible after key press
282        // (scroll actions will set it again if needed). Use the *effective*
283        // active split so this clears the flag on a focused buffer-group
284        // panel's own view state, not the group host's — without this, a
285        // scroll action in the panel (mouse scrollbar click, plugin
286        // scrollBufferToLine, etc.) sets `skip_ensure_visible` on the panel
287        // and subsequent key presses never clear it, so cursor motion stops
288        // scrolling the viewport.
289        let active_split = self.effective_active_split();
290        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
291            view_state.viewport.clear_skip_ensure_visible();
292        }
293
294        // Dismiss theme info popup on any key press
295        if self.theme_info_popup.is_some() {
296            self.theme_info_popup = None;
297        }
298
299        if self.file_explorer_context_menu.is_some() {
300            if let Some(result) = self.handle_file_explorer_context_menu_key(code, modifiers) {
301                return result;
302            }
303        }
304
305        // Determine the current context first
306        let mut context = self.get_key_context();
307
308        // Special case: Hover and Signature Help popups should be dismissed on any key press
309        // EXCEPT for Ctrl+C when the popup has a text selection (allow copy first).
310        //
311        // Fires for both focused and unfocused popups: an unfocused
312        // hover popup that floats over the buffer must still vanish when
313        // the user starts typing — otherwise it lingers indefinitely
314        // because no key event reaches it. The focused-popup path also
315        // covers the legacy case where a transient popup was given
316        // focus (e.g. via the focus-popup keybinding).
317        let popup_visible_on_screen =
318            self.global_popups.is_visible() || self.active_state().popups.is_visible();
319        if popup_visible_on_screen {
320            // Check if the current popup is transient (hover, signature help).
321            // Editor-level popups always take precedence over buffer popups
322            // when both are visible — they're effectively modal overlays.
323            let (is_transient_popup, has_selection) = {
324                let popup = self
325                    .global_popups
326                    .top()
327                    .or_else(|| self.active_state().popups.top());
328                (
329                    popup.is_some_and(|p| p.transient),
330                    popup.is_some_and(|p| p.has_selection()),
331                )
332            };
333
334            // Don't dismiss if popup has selection and user is pressing Ctrl+C (let them copy first)
335            let is_copy_key = key_event.code == crossterm::event::KeyCode::Char('c')
336                && key_event
337                    .modifiers
338                    .contains(crossterm::event::KeyModifiers::CONTROL);
339
340            // Skip the dismiss when the user is *transferring* focus to
341            // the popup — otherwise pressing the focus-popup key while
342            // a transient popup is on screen would close the popup
343            // before its handler ever sees the focus action.
344            let resolved_action = self
345                .keybindings
346                .read()
347                .ok()
348                .map(|kb| kb.resolve(&key_event, context.clone()));
349            let is_focus_popup_key = matches!(
350                resolved_action,
351                Some(crate::input::keybindings::Action::PopupFocus)
352            );
353
354            if is_transient_popup && !(has_selection && is_copy_key) && !is_focus_popup_key {
355                // Dismiss the popup on any key press (except Ctrl+C with selection)
356                self.hide_popup();
357                tracing::debug!("Dismissed transient popup on key press");
358                // Recalculate context now that popup is gone
359                context = self.get_key_context();
360            }
361        }
362
363        // Unfocused popup control: even though an unfocused popup
364        // doesn't claim the keyboard, the user's bound popup-cancel
365        // (default Esc) and popup-focus (default Alt+T) keys must
366        // still affect it. Resolved here, *before* the modal
367        // dispatcher routes the key to the buffer/explorer/etc.
368        if let Some(action) = self.resolve_unfocused_popup_action(&key_event) {
369            self.handle_action(action)?;
370            return Ok(());
371        }
372
373        // Try hierarchical modal input dispatch first (Settings, Menu, Prompt, Popup)
374        if self.dispatch_modal_input(&key_event).is_some() {
375            return Ok(());
376        }
377
378        // If a modal was dismissed (e.g., completion popup closed and returned Ignored),
379        // recalculate the context so the key is processed in the correct context.
380        if context != self.get_key_context() {
381            context = self.get_key_context();
382        }
383
384        // Only check buffer mode keybindings when the editor buffer has focus.
385        // FileExplorer, Menu, Prompt, Popup contexts should not trigger mode bindings
386        // (e.g. markdown-source's Enter handler should not fire while the explorer is focused).
387        let should_check_mode_bindings =
388            matches!(context, crate::input::keybindings::KeyContext::Normal);
389
390        if should_check_mode_bindings {
391            // effective_mode() returns buffer-local mode if present, else global mode.
392            // This ensures virtual buffer modes aren't hijacked by global modes.
393            let effective_mode = self.effective_mode().map(|s| s.to_owned());
394
395            if let Some(ref mode_name) = effective_mode {
396                let mode_ctx = crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
397                let key_event = crossterm::event::KeyEvent::new(code, modifiers);
398
399                // Mode chord resolution (via KeybindingResolver)
400                let (chord_result, resolved_action) = {
401                    let keybindings = self.keybindings.read().unwrap();
402                    let chord_result =
403                        keybindings.resolve_chord(&self.chord_state, &key_event, mode_ctx.clone());
404                    let resolved = keybindings.resolve(&key_event, mode_ctx);
405                    (chord_result, resolved)
406                };
407                match chord_result {
408                    crate::input::keybindings::ChordResolution::Complete(action) => {
409                        tracing::debug!("Mode chord resolved to action: {:?}", action);
410                        self.chord_state.clear();
411                        return self.handle_action(action);
412                    }
413                    crate::input::keybindings::ChordResolution::Partial => {
414                        tracing::debug!("Potential chord prefix in mode '{}'", mode_name);
415                        self.chord_state.push((code, modifiers));
416                        return Ok(());
417                    }
418                    crate::input::keybindings::ChordResolution::NoMatch => {
419                        if !self.chord_state.is_empty() {
420                            tracing::debug!("Chord sequence abandoned in mode, clearing state");
421                            self.chord_state.clear();
422                        }
423                    }
424                }
425
426                // Mode single-key resolution (custom > keymap > plugin defaults)
427                if resolved_action != Action::None {
428                    return self.handle_action(resolved_action);
429                }
430            }
431
432            // Handle unbound keys for modes that want to capture input.
433            //
434            // Buffer-local modes with allow_text_input (e.g. search-replace-list)
435            // capture character keys and block other unbound keys.
436            //
437            // Buffer-local modes WITHOUT allow_text_input (e.g. diff-view) let
438            // unbound keys fall through to normal keybinding handling so that
439            // Ctrl+C, arrows, etc. still work.
440            //
441            // Global editor modes (e.g. vi-normal) block all unbound keys when
442            // read-only.
443            if let Some(ref mode_name) = effective_mode {
444                if self.mode_registry.allows_text_input(mode_name) {
445                    if let KeyCode::Char(c) = code {
446                        let ch = if modifiers.contains(KeyModifiers::SHIFT) {
447                            c.to_uppercase().next().unwrap_or(c)
448                        } else {
449                            c
450                        };
451                        if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
452                            let action_name = format!("mode_text_input:{}", ch);
453                            return self.handle_action(Action::PluginAction(action_name));
454                        }
455                    }
456                    tracing::debug!("Blocking unbound key in text-input mode '{}'", mode_name);
457                    return Ok(());
458                }
459            }
460            if let Some(ref mode_name) = self.editor_mode {
461                if self.mode_registry.is_read_only(mode_name) {
462                    tracing::debug!("Ignoring unbound key in read-only mode '{}'", mode_name);
463                    return Ok(());
464                }
465                tracing::debug!(
466                    "Mode '{}' is not read-only, allowing key through",
467                    mode_name
468                );
469            }
470        }
471
472        // --- Composite buffer input routing ---
473        // If the active buffer is a composite buffer (side-by-side diff),
474        // route remaining composite-specific keys (scroll, pane switch, close)
475        // through CompositeInputRouter before falling through to regular
476        // keybinding resolution. Hunk navigation (n/p/]/[) is handled by the
477        // Action system via CompositeBuffer context bindings.
478        {
479            let active_buf = self.active_buffer();
480            let active_split = self.effective_active_split();
481            if self.is_composite_buffer(active_buf) {
482                if let Some(handled) =
483                    self.try_route_composite_key(active_split, active_buf, &key_event)
484                {
485                    return handled;
486                }
487            }
488        }
489
490        // Check for chord sequence matches first
491        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
492        let (chord_result, action) = {
493            let keybindings = self.keybindings.read().unwrap();
494            let chord_result =
495                keybindings.resolve_chord(&self.chord_state, &key_event, context.clone());
496            let action = keybindings.resolve(&key_event, context.clone());
497            (chord_result, action)
498        };
499
500        match chord_result {
501            crate::input::keybindings::ChordResolution::Complete(action) => {
502                // Complete chord match - execute action and clear chord state
503                tracing::debug!("Complete chord match -> Action: {:?}", action);
504                self.chord_state.clear();
505                return self.handle_action(action);
506            }
507            crate::input::keybindings::ChordResolution::Partial => {
508                // Partial match - add to chord state and wait for more keys
509                tracing::debug!("Partial chord match - waiting for next key");
510                self.chord_state.push((code, modifiers));
511                return Ok(());
512            }
513            crate::input::keybindings::ChordResolution::NoMatch => {
514                // No chord match - clear state and try regular resolution
515                if !self.chord_state.is_empty() {
516                    tracing::debug!("Chord sequence abandoned, clearing state");
517                    self.chord_state.clear();
518                }
519            }
520        }
521
522        // Regular single-key resolution (already resolved above)
523        tracing::trace!("Context: {:?} -> Action: {:?}", context, action);
524
525        // Cancel pending LSP requests on user actions (except LSP actions themselves)
526        // This ensures stale completions don't show up after the user has moved on
527        match action {
528            Action::LspCompletion
529            | Action::LspGotoDefinition
530            | Action::LspReferences
531            | Action::LspHover
532            | Action::None => {
533                // Don't cancel for LSP actions or no-op
534            }
535            _ => {
536                // Cancel any pending LSP requests
537                self.cancel_pending_lsp_requests();
538            }
539        }
540
541        // Note: Modal components (Settings, Menu, Prompt, Popup, File Browser) are now
542        // handled by dispatch_modal_input using the InputHandler system.
543        // All remaining actions delegate to handle_action.
544        self.handle_action(action)
545    }
546
547    /// Handle an action (for normal mode and command execution).
548    /// Used by the app module internally and by the GUI module for native menu dispatch.
549    pub(crate) fn handle_action(&mut self, action: Action) -> AnyhowResult<()> {
550        use crate::input::keybindings::Action;
551
552        // Record action to macro if recording
553        self.record_macro_action(&action);
554
555        // Reset dabbrev cycling session on any non-dabbrev action.
556        if !matches!(action, Action::DabbrevExpand) {
557            self.reset_dabbrev_state();
558        }
559
560        match action {
561            Action::Quit => self.quit(),
562            Action::ForceQuit => {
563                self.should_quit = true;
564            }
565            Action::Detach => {
566                self.should_detach = true;
567            }
568            Action::Save => {
569                // Check if buffer has a file path - if not, redirect to SaveAs
570                if self.active_state().buffer.file_path().is_none() {
571                    self.start_prompt_with_initial_text(
572                        t!("file.save_as_prompt").to_string(),
573                        PromptType::SaveFileAs,
574                        String::new(),
575                    );
576                    self.init_file_open_state();
577                } else if self.check_save_conflict().is_some() {
578                    // Check if file was modified externally since we opened/saved it
579                    self.start_prompt(
580                        t!("file.file_changed_prompt").to_string(),
581                        PromptType::ConfirmSaveConflict,
582                    );
583                } else if let Err(e) = self.save() {
584                    let msg = format!("{}", e);
585                    self.status_message = Some(t!("file.save_failed", error = &msg).to_string());
586                }
587            }
588            Action::SaveAs => {
589                // Get current filename as default suggestion
590                let current_path = self
591                    .active_state()
592                    .buffer
593                    .file_path()
594                    .map(|p| {
595                        // Make path relative to working_dir if possible
596                        p.strip_prefix(&self.working_dir)
597                            .unwrap_or(p)
598                            .to_string_lossy()
599                            .to_string()
600                    })
601                    .unwrap_or_default();
602                self.start_prompt_with_initial_text(
603                    t!("file.save_as_prompt").to_string(),
604                    PromptType::SaveFileAs,
605                    current_path,
606                );
607                self.init_file_open_state();
608            }
609            Action::Open => {
610                self.start_prompt(t!("file.open_prompt").to_string(), PromptType::OpenFile);
611                self.prefill_open_file_prompt();
612                self.init_file_open_state();
613            }
614            Action::SwitchProject => {
615                self.start_prompt(
616                    t!("file.switch_project_prompt").to_string(),
617                    PromptType::SwitchProject,
618                );
619                self.init_folder_open_state();
620            }
621            Action::GotoLine => {
622                let has_line_index = self
623                    .buffers
624                    .get(&self.active_buffer())
625                    .is_none_or(|s| s.buffer.line_count().is_some());
626                if has_line_index {
627                    self.start_prompt(
628                        t!("file.goto_line_prompt").to_string(),
629                        PromptType::GotoLine,
630                    );
631                } else {
632                    self.start_prompt(
633                        t!("goto.scan_confirm_prompt", yes = "y", no = "N").to_string(),
634                        PromptType::GotoLineScanConfirm,
635                    );
636                }
637            }
638            Action::ScanLineIndex => {
639                self.start_incremental_line_scan(false);
640            }
641            Action::New => {
642                self.new_buffer();
643            }
644            Action::Close | Action::CloseTab => {
645                // Both Close and CloseTab use close_tab() which handles:
646                // - Closing the split if this is the last buffer and there are other splits
647                // - Prompting for unsaved changes
648                // - Properly closing the buffer
649                self.close_tab();
650            }
651            Action::Revert => {
652                // Check if buffer has unsaved changes - prompt for confirmation
653                if self.active_state().buffer.is_modified() {
654                    let revert_key = t!("prompt.key.revert").to_string();
655                    let cancel_key = t!("prompt.key.cancel").to_string();
656                    self.start_prompt(
657                        t!(
658                            "prompt.revert_confirm",
659                            revert_key = revert_key,
660                            cancel_key = cancel_key
661                        )
662                        .to_string(),
663                        PromptType::ConfirmRevert,
664                    );
665                } else {
666                    // No local changes, just revert
667                    if let Err(e) = self.revert_file() {
668                        self.set_status_message(
669                            t!("error.failed_to_revert", error = e.to_string()).to_string(),
670                        );
671                    }
672                }
673            }
674            Action::ToggleAutoRevert => {
675                self.toggle_auto_revert();
676            }
677            Action::FormatBuffer => {
678                if let Err(e) = self.format_buffer() {
679                    self.set_status_message(
680                        t!("error.format_failed", error = e.to_string()).to_string(),
681                    );
682                }
683            }
684            Action::TrimTrailingWhitespace => match self.trim_trailing_whitespace() {
685                Ok(true) => {
686                    self.set_status_message(t!("whitespace.trimmed").to_string());
687                }
688                Ok(false) => {
689                    self.set_status_message(t!("whitespace.no_trailing").to_string());
690                }
691                Err(e) => {
692                    self.set_status_message(
693                        t!("error.trim_whitespace_failed", error = e).to_string(),
694                    );
695                }
696            },
697            Action::EnsureFinalNewline => match self.ensure_final_newline() {
698                Ok(true) => {
699                    self.set_status_message(t!("whitespace.newline_added").to_string());
700                }
701                Ok(false) => {
702                    self.set_status_message(t!("whitespace.already_has_newline").to_string());
703                }
704                Err(e) => {
705                    self.set_status_message(
706                        t!("error.ensure_newline_failed", error = e).to_string(),
707                    );
708                }
709            },
710            Action::Copy => {
711                // Editor-level popups take precedence over everything, including the file explorer.
712                let popup = self
713                    .global_popups
714                    .top()
715                    .or_else(|| self.active_state().popups.top());
716                if let Some(popup) = popup {
717                    if popup.has_selection() {
718                        if let Some(text) = popup.get_selected_text() {
719                            self.clipboard.copy(text);
720                            self.set_status_message(t!("clipboard.copied").to_string());
721                            return Ok(());
722                        }
723                    }
724                }
725                if self.key_context == crate::input::keybindings::KeyContext::FileExplorer {
726                    self.file_explorer_copy();
727                    return Ok(());
728                }
729                // Check if active buffer is a composite buffer
730                let buffer_id = self.active_buffer();
731                if self.is_composite_buffer(buffer_id) {
732                    if let Some(_handled) = self.handle_composite_action(buffer_id, &Action::Copy) {
733                        return Ok(());
734                    }
735                }
736                self.copy_selection()
737            }
738            Action::CopyWithTheme(theme) => self.copy_selection_with_theme(&theme),
739            Action::CopyFilePath => self.copy_active_buffer_path(false),
740            Action::CopyRelativeFilePath => self.copy_active_buffer_path(true),
741            Action::Cut => {
742                if self.key_context == crate::input::keybindings::KeyContext::FileExplorer {
743                    self.file_explorer_cut();
744                    return Ok(());
745                }
746                if self.is_editing_disabled() {
747                    self.set_status_message(t!("buffer.editing_disabled").to_string());
748                    return Ok(());
749                }
750                self.cut_selection()
751            }
752            Action::Paste => {
753                if self.key_context == crate::input::keybindings::KeyContext::FileExplorer {
754                    self.file_explorer_paste();
755                    return Ok(());
756                }
757                if self.is_editing_disabled() {
758                    self.set_status_message(t!("buffer.editing_disabled").to_string());
759                    return Ok(());
760                }
761                self.paste()
762            }
763            Action::YankWordForward => self.yank_word_forward(),
764            Action::YankWordBackward => self.yank_word_backward(),
765            Action::YankToLineEnd => self.yank_to_line_end(),
766            Action::YankToLineStart => self.yank_to_line_start(),
767            Action::YankViWordEnd => self.yank_vi_word_end(),
768            Action::Undo => {
769                self.handle_undo();
770            }
771            Action::Redo => {
772                self.handle_redo();
773            }
774            Action::ShowHelp => {
775                self.open_help_manual();
776            }
777            Action::ShowKeyboardShortcuts => {
778                self.open_keyboard_shortcuts();
779            }
780            Action::ShowWarnings => {
781                self.show_warnings_popup();
782            }
783            Action::ShowStatusLog => {
784                self.open_status_log();
785            }
786            Action::ShowLspStatus => {
787                self.show_lsp_status_popup();
788            }
789            Action::ShowRemoteIndicatorMenu => {
790                self.show_remote_indicator_popup();
791            }
792            Action::ClearWarnings => {
793                self.clear_warnings();
794            }
795            Action::CommandPalette => {
796                // CommandPalette now delegates to QuickOpen (which starts with ">" prefix
797                // for command mode). Toggle if already open.
798                if let Some(prompt) = &self.prompt {
799                    if prompt.prompt_type == PromptType::QuickOpen {
800                        self.cancel_prompt();
801                        return Ok(());
802                    }
803                }
804                self.start_quick_open();
805            }
806            Action::QuickOpen => {
807                // Toggle Quick Open: close if already open, otherwise open it
808                if let Some(prompt) = &self.prompt {
809                    if prompt.prompt_type == PromptType::QuickOpen {
810                        self.cancel_prompt();
811                        return Ok(());
812                    }
813                }
814
815                // Start Quick Open with file suggestions (default mode)
816                self.start_quick_open();
817            }
818            Action::QuickOpenBuffers => {
819                if let Some(prompt) = &self.prompt {
820                    if prompt.prompt_type == PromptType::QuickOpen {
821                        self.cancel_prompt();
822                        return Ok(());
823                    }
824                }
825                self.start_quick_open_with_prefix("#");
826            }
827            Action::QuickOpenFiles => {
828                if let Some(prompt) = &self.prompt {
829                    if prompt.prompt_type == PromptType::QuickOpen {
830                        self.cancel_prompt();
831                        return Ok(());
832                    }
833                }
834                self.start_quick_open_with_prefix("");
835            }
836            Action::ToggleLineWrap => {
837                let new_value = !self.config.editor.line_wrap;
838                self.config_mut().editor.line_wrap = new_value;
839
840                // Update all viewports to reflect the new line wrap setting,
841                // respecting per-language overrides
842                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
843                for leaf_id in leaf_ids {
844                    let buffer_id = self
845                        .split_manager
846                        .get_buffer_id(leaf_id.into())
847                        .unwrap_or(BufferId(0));
848                    let effective_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
849                    let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
850                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
851                        view_state.viewport.line_wrap_enabled = effective_wrap;
852                        view_state.viewport.wrap_indent = self.config.editor.wrap_indent;
853                        view_state.viewport.wrap_column = wrap_column;
854                    }
855                }
856
857                let state = if self.config.editor.line_wrap {
858                    t!("view.state_enabled").to_string()
859                } else {
860                    t!("view.state_disabled").to_string()
861                };
862                self.set_status_message(t!("view.line_wrap_state", state = state).to_string());
863            }
864            Action::ToggleCurrentLineHighlight => {
865                let new_value = !self.config.editor.highlight_current_line;
866                self.config_mut().editor.highlight_current_line = new_value;
867
868                // Update all splits
869                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
870                for leaf_id in leaf_ids {
871                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
872                        view_state.highlight_current_line =
873                            self.config.editor.highlight_current_line;
874                    }
875                }
876
877                let state = if self.config.editor.highlight_current_line {
878                    t!("view.state_enabled").to_string()
879                } else {
880                    t!("view.state_disabled").to_string()
881                };
882                self.set_status_message(
883                    t!("view.current_line_highlight_state", state = state).to_string(),
884                );
885            }
886            Action::ToggleReadOnly => {
887                let buffer_id = self.active_buffer();
888                let is_now_read_only = self
889                    .buffer_metadata
890                    .get(&buffer_id)
891                    .map(|m| !m.read_only)
892                    .unwrap_or(false);
893                self.mark_buffer_read_only(buffer_id, is_now_read_only);
894
895                let state_str = if is_now_read_only {
896                    t!("view.state_enabled").to_string()
897                } else {
898                    t!("view.state_disabled").to_string()
899                };
900                self.set_status_message(t!("view.read_only_state", state = state_str).to_string());
901            }
902            Action::TogglePageView => {
903                self.handle_toggle_page_view();
904            }
905            Action::SetPageWidth => {
906                let active_split = self.split_manager.active_split();
907                let current = self
908                    .split_view_states
909                    .get(&active_split)
910                    .and_then(|v| v.compose_width.map(|w| w.to_string()))
911                    .unwrap_or_default();
912                self.start_prompt_with_initial_text(
913                    "Page width (empty = viewport): ".to_string(),
914                    PromptType::SetPageWidth,
915                    current,
916                );
917            }
918            Action::SetBackground => {
919                let default_path = self
920                    .ansi_background_path
921                    .as_ref()
922                    .and_then(|p| {
923                        p.strip_prefix(&self.working_dir)
924                            .ok()
925                            .map(|rel| rel.to_string_lossy().to_string())
926                    })
927                    .unwrap_or_else(|| DEFAULT_BACKGROUND_FILE.to_string());
928
929                self.start_prompt_with_initial_text(
930                    "Background file: ".to_string(),
931                    PromptType::SetBackgroundFile,
932                    default_path,
933                );
934            }
935            Action::SetBackgroundBlend => {
936                let default_amount = format!("{:.2}", self.background_fade);
937                self.start_prompt_with_initial_text(
938                    "Background blend (0-1): ".to_string(),
939                    PromptType::SetBackgroundBlend,
940                    default_amount,
941                );
942            }
943            Action::LspCompletion => {
944                self.request_completion();
945            }
946            Action::DabbrevExpand => {
947                self.dabbrev_expand();
948            }
949            Action::LspGotoDefinition => {
950                self.request_goto_definition()?;
951            }
952            Action::LspRename => {
953                self.start_rename()?;
954            }
955            Action::LspHover => {
956                self.request_hover()?;
957            }
958            Action::LspReferences => {
959                self.request_references()?;
960            }
961            Action::LspSignatureHelp => {
962                self.request_signature_help();
963            }
964            Action::LspCodeActions => {
965                self.request_code_actions()?;
966            }
967            Action::LspRestart => {
968                self.handle_lsp_restart();
969            }
970            Action::LspStop => {
971                self.handle_lsp_stop();
972            }
973            Action::LspToggleForBuffer => {
974                self.handle_lsp_toggle_for_buffer();
975            }
976            Action::ToggleInlayHints => {
977                self.toggle_inlay_hints();
978            }
979            Action::DumpConfig => {
980                self.dump_config();
981            }
982            Action::RedrawScreen => {
983                self.request_full_redraw();
984            }
985            Action::SelectTheme => {
986                self.start_select_theme_prompt();
987            }
988            Action::InspectThemeAtCursor => {
989                self.inspect_theme_at_cursor();
990            }
991            Action::SelectKeybindingMap => {
992                self.start_select_keybinding_map_prompt();
993            }
994            Action::SelectCursorStyle => {
995                self.start_select_cursor_style_prompt();
996            }
997            Action::SelectLocale => {
998                self.start_select_locale_prompt();
999            }
1000            Action::Search => {
1001                // If already in a search-related prompt, Ctrl+F acts like Enter (confirm search)
1002                let is_search_prompt = self.prompt.as_ref().is_some_and(|p| {
1003                    matches!(
1004                        p.prompt_type,
1005                        PromptType::Search
1006                            | PromptType::ReplaceSearch
1007                            | PromptType::QueryReplaceSearch
1008                    )
1009                });
1010
1011                if is_search_prompt {
1012                    self.confirm_prompt();
1013                } else {
1014                    self.start_search_prompt(
1015                        t!("file.search_prompt").to_string(),
1016                        PromptType::Search,
1017                        false,
1018                    );
1019                }
1020            }
1021            Action::Replace => {
1022                // Use same flow as query-replace, just with confirm_each defaulting to false
1023                self.start_search_prompt(
1024                    t!("file.replace_prompt").to_string(),
1025                    PromptType::ReplaceSearch,
1026                    false,
1027                );
1028            }
1029            Action::QueryReplace => {
1030                // Enable confirm mode by default for query-replace
1031                self.search_confirm_each = true;
1032                self.start_search_prompt(
1033                    "Query replace: ".to_string(),
1034                    PromptType::QueryReplaceSearch,
1035                    false,
1036                );
1037            }
1038            Action::FindInSelection => {
1039                self.start_search_prompt(
1040                    t!("file.search_prompt").to_string(),
1041                    PromptType::Search,
1042                    true,
1043                );
1044            }
1045            Action::FindNext => {
1046                self.find_next();
1047            }
1048            Action::FindPrevious => {
1049                self.find_previous();
1050            }
1051            Action::FindSelectionNext => {
1052                self.find_selection_next();
1053            }
1054            Action::FindSelectionPrevious => {
1055                self.find_selection_previous();
1056            }
1057            Action::AddCursorNextMatch => self.add_cursor_at_next_match(),
1058            Action::AddCursorAbove => self.add_cursor_above(),
1059            Action::AddCursorBelow => self.add_cursor_below(),
1060            Action::NextBuffer => self.next_buffer(),
1061            Action::PrevBuffer => self.prev_buffer(),
1062            Action::SwitchToPreviousTab => self.switch_to_previous_tab(),
1063            Action::SwitchToTabByName => self.start_switch_to_tab_prompt(),
1064
1065            // Tab scrolling (manual scroll - don't auto-adjust)
1066            Action::ScrollTabsLeft => {
1067                let active_split_id = self.split_manager.active_split();
1068                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
1069                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_sub(5);
1070                    self.set_status_message(t!("status.scrolled_tabs_left").to_string());
1071                }
1072            }
1073            Action::ScrollTabsRight => {
1074                let active_split_id = self.split_manager.active_split();
1075                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
1076                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_add(5);
1077                    self.set_status_message(t!("status.scrolled_tabs_right").to_string());
1078                }
1079            }
1080            Action::NavigateBack => self.navigate_back(),
1081            Action::NavigateForward => self.navigate_forward(),
1082            Action::SplitHorizontal => self.split_pane_horizontal(),
1083            Action::SplitVertical => self.split_pane_vertical(),
1084            Action::CloseSplit => self.close_active_split(),
1085            Action::NextSplit => self.next_split(),
1086            Action::PrevSplit => self.prev_split(),
1087            Action::IncreaseSplitSize => self.adjust_split_size(0.05),
1088            Action::DecreaseSplitSize => self.adjust_split_size(-0.05),
1089            Action::ToggleMaximizeSplit => self.toggle_maximize_split(),
1090            Action::ToggleFileExplorer => self.toggle_file_explorer(),
1091            Action::ToggleMenuBar => self.toggle_menu_bar(),
1092            Action::ToggleTabBar => self.toggle_tab_bar(),
1093            Action::ToggleStatusBar => self.toggle_status_bar(),
1094            Action::TogglePromptLine => self.toggle_prompt_line(),
1095            Action::ToggleVerticalScrollbar => self.toggle_vertical_scrollbar(),
1096            Action::ToggleHorizontalScrollbar => self.toggle_horizontal_scrollbar(),
1097            Action::ToggleLineNumbers => self.toggle_line_numbers(),
1098            Action::ToggleScrollSync => self.toggle_scroll_sync(),
1099            Action::ToggleMouseCapture => self.toggle_mouse_capture(),
1100            Action::ToggleMouseHover => self.toggle_mouse_hover(),
1101            Action::ToggleDebugHighlights => self.toggle_debug_highlights(),
1102            // Rulers
1103            Action::AddRuler => {
1104                self.start_prompt(t!("rulers.add_prompt").to_string(), PromptType::AddRuler);
1105            }
1106            Action::RemoveRuler => {
1107                self.start_remove_ruler_prompt();
1108            }
1109            // Buffer settings
1110            Action::SetTabSize => {
1111                let current = self
1112                    .buffers
1113                    .get(&self.active_buffer())
1114                    .map(|s| s.buffer_settings.tab_size.to_string())
1115                    .unwrap_or_else(|| "4".to_string());
1116                self.start_prompt_with_initial_text(
1117                    "Tab size: ".to_string(),
1118                    PromptType::SetTabSize,
1119                    current,
1120                );
1121            }
1122            Action::SetLineEnding => {
1123                self.start_set_line_ending_prompt();
1124            }
1125            Action::SetEncoding => {
1126                self.start_set_encoding_prompt();
1127            }
1128            Action::ReloadWithEncoding => {
1129                self.start_reload_with_encoding_prompt();
1130            }
1131            Action::SetLanguage => {
1132                self.start_set_language_prompt();
1133            }
1134            Action::ToggleIndentationStyle => {
1135                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
1136                    state.buffer_settings.use_tabs = !state.buffer_settings.use_tabs;
1137                    let status = if state.buffer_settings.use_tabs {
1138                        "Indentation: Tabs"
1139                    } else {
1140                        "Indentation: Spaces"
1141                    };
1142                    self.set_status_message(status.to_string());
1143                }
1144            }
1145            Action::ToggleTabIndicators | Action::ToggleWhitespaceIndicators => {
1146                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
1147                    state.buffer_settings.whitespace.toggle_all();
1148                    let status = if state.buffer_settings.whitespace.any_visible() {
1149                        t!("toggle.whitespace_indicators_shown")
1150                    } else {
1151                        t!("toggle.whitespace_indicators_hidden")
1152                    };
1153                    self.set_status_message(status.to_string());
1154                }
1155            }
1156            Action::ResetBufferSettings => self.reset_buffer_settings(),
1157            Action::FocusFileExplorer => self.focus_file_explorer(),
1158            Action::FocusEditor => self.focus_editor(),
1159            Action::FileExplorerUp => self.file_explorer_navigate_up(),
1160            Action::FileExplorerDown => self.file_explorer_navigate_down(),
1161            Action::FileExplorerPageUp => self.file_explorer_page_up(),
1162            Action::FileExplorerPageDown => self.file_explorer_page_down(),
1163            Action::FileExplorerExpand => self.file_explorer_toggle_expand(),
1164            Action::FileExplorerCollapse => self.file_explorer_collapse(),
1165            Action::FileExplorerOpen => self.file_explorer_open_file()?,
1166            Action::FileExplorerRefresh => self.file_explorer_refresh(),
1167            Action::FileExplorerNewFile => self.file_explorer_new_file(),
1168            Action::FileExplorerNewDirectory => self.file_explorer_new_directory(),
1169            Action::FileExplorerDelete => self.file_explorer_delete(),
1170            Action::FileExplorerRename => self.file_explorer_rename(),
1171            Action::FileExplorerToggleHidden => self.file_explorer_toggle_hidden(),
1172            Action::FileExplorerToggleGitignored => self.file_explorer_toggle_gitignored(),
1173            Action::FileExplorerSearchClear => self.file_explorer_search_clear(),
1174            Action::FileExplorerSearchBackspace => self.file_explorer_search_pop_char(),
1175            Action::FileExplorerCopy => self.file_explorer_copy(),
1176            Action::FileExplorerCut => self.file_explorer_cut(),
1177            Action::FileExplorerPaste => self.file_explorer_paste(),
1178            Action::FileExplorerExtendSelectionUp => self.file_explorer_extend_selection_up(),
1179            Action::FileExplorerExtendSelectionDown => self.file_explorer_extend_selection_down(),
1180            Action::FileExplorerToggleSelect => self.file_explorer_toggle_select(),
1181            Action::FileExplorerSelectAll => self.file_explorer_select_all(),
1182            Action::RemoveSecondaryCursors => {
1183                // Convert action to events and apply them
1184                if let Some(events) = self.action_to_events(Action::RemoveSecondaryCursors) {
1185                    // Wrap in batch for atomic undo
1186                    let batch = Event::Batch {
1187                        events: events.clone(),
1188                        description: "Remove secondary cursors".to_string(),
1189                    };
1190                    self.active_event_log_mut().append(batch.clone());
1191                    self.apply_event_to_active_buffer(&batch);
1192
1193                    // Ensure the primary cursor is visible after removing secondary cursors
1194                    let active_split = self.split_manager.active_split();
1195                    let active_buffer = self.active_buffer();
1196                    if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1197                        let state = self.buffers.get_mut(&active_buffer).unwrap();
1198                        view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
1199                    }
1200                }
1201            }
1202
1203            // Menu navigation actions
1204            Action::MenuActivate => {
1205                self.handle_menu_activate();
1206            }
1207            Action::MenuClose => {
1208                self.handle_menu_close();
1209            }
1210            Action::MenuLeft => {
1211                self.handle_menu_left();
1212            }
1213            Action::MenuRight => {
1214                self.handle_menu_right();
1215            }
1216            Action::MenuUp => {
1217                self.handle_menu_up();
1218            }
1219            Action::MenuDown => {
1220                self.handle_menu_down();
1221            }
1222            Action::MenuExecute => {
1223                if let Some(action) = self.handle_menu_execute() {
1224                    return self.handle_action(action);
1225                }
1226            }
1227            Action::MenuOpen(menu_name) => {
1228                if self.config.editor.menu_bar_mnemonics {
1229                    self.handle_menu_open(&menu_name);
1230                }
1231            }
1232
1233            Action::SwitchKeybindingMap(map_name) => {
1234                // Check if the map exists (either built-in or user-defined)
1235                let is_builtin =
1236                    matches!(map_name.as_str(), "default" | "emacs" | "vscode" | "macos");
1237                let is_user_defined = self.config.keybinding_maps.contains_key(&map_name);
1238
1239                if is_builtin || is_user_defined {
1240                    // Update the active keybinding map in config
1241                    self.config_mut().active_keybinding_map = map_name.clone().into();
1242
1243                    // Reload the keybinding resolver with the new map
1244                    *self.keybindings.write().unwrap() =
1245                        crate::input::keybindings::KeybindingResolver::new(&self.config);
1246
1247                    self.set_status_message(
1248                        t!("view.keybindings_switched", map = map_name).to_string(),
1249                    );
1250                } else {
1251                    self.set_status_message(
1252                        t!("view.keybindings_unknown", map = map_name).to_string(),
1253                    );
1254                }
1255            }
1256
1257            Action::SmartHome => {
1258                // In composite (diff) views, use LineStart movement
1259                let buffer_id = self.active_buffer();
1260                if self.is_composite_buffer(buffer_id) {
1261                    if let Some(_handled) =
1262                        self.handle_composite_action(buffer_id, &Action::SmartHome)
1263                    {
1264                        return Ok(());
1265                    }
1266                }
1267                self.smart_home();
1268            }
1269            Action::ToggleComment => {
1270                self.toggle_comment();
1271            }
1272            Action::ToggleFold => {
1273                self.toggle_fold_at_cursor();
1274            }
1275            Action::GoToMatchingBracket => {
1276                self.goto_matching_bracket();
1277            }
1278            Action::JumpToNextError => {
1279                self.jump_to_next_error();
1280            }
1281            Action::JumpToPreviousError => {
1282                self.jump_to_previous_error();
1283            }
1284            Action::SetBookmark(key) => {
1285                self.set_bookmark(key);
1286            }
1287            Action::JumpToBookmark(key) => {
1288                self.jump_to_bookmark(key);
1289            }
1290            Action::ClearBookmark(key) => {
1291                self.clear_bookmark(key);
1292            }
1293            Action::ListBookmarks => {
1294                self.list_bookmarks();
1295            }
1296            Action::ToggleSearchCaseSensitive => {
1297                self.search_case_sensitive = !self.search_case_sensitive;
1298                let state = if self.search_case_sensitive {
1299                    "enabled"
1300                } else {
1301                    "disabled"
1302                };
1303                self.set_status_message(
1304                    t!("search.case_sensitive_state", state = state).to_string(),
1305                );
1306                // Update incremental highlights if in search prompt, otherwise re-run completed search
1307                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1308                if let Some(prompt) = &self.prompt {
1309                    if matches!(
1310                        prompt.prompt_type,
1311                        PromptType::Search
1312                            | PromptType::ReplaceSearch
1313                            | PromptType::QueryReplaceSearch
1314                    ) {
1315                        let query = prompt.input.clone();
1316                        self.update_search_highlights(&query);
1317                    }
1318                } else if let Some(search_state) = &self.search_state {
1319                    let query = search_state.query.clone();
1320                    self.perform_search(&query);
1321                }
1322            }
1323            Action::ToggleSearchWholeWord => {
1324                self.search_whole_word = !self.search_whole_word;
1325                let state = if self.search_whole_word {
1326                    "enabled"
1327                } else {
1328                    "disabled"
1329                };
1330                self.set_status_message(t!("search.whole_word_state", state = state).to_string());
1331                // Update incremental highlights if in search prompt, otherwise re-run completed search
1332                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1333                if let Some(prompt) = &self.prompt {
1334                    if matches!(
1335                        prompt.prompt_type,
1336                        PromptType::Search
1337                            | PromptType::ReplaceSearch
1338                            | PromptType::QueryReplaceSearch
1339                    ) {
1340                        let query = prompt.input.clone();
1341                        self.update_search_highlights(&query);
1342                    }
1343                } else if let Some(search_state) = &self.search_state {
1344                    let query = search_state.query.clone();
1345                    self.perform_search(&query);
1346                }
1347            }
1348            Action::ToggleSearchRegex => {
1349                self.search_use_regex = !self.search_use_regex;
1350                let state = if self.search_use_regex {
1351                    "enabled"
1352                } else {
1353                    "disabled"
1354                };
1355                self.set_status_message(t!("search.regex_state", state = state).to_string());
1356                // Update incremental highlights if in search prompt, otherwise re-run completed search
1357                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1358                if let Some(prompt) = &self.prompt {
1359                    if matches!(
1360                        prompt.prompt_type,
1361                        PromptType::Search
1362                            | PromptType::ReplaceSearch
1363                            | PromptType::QueryReplaceSearch
1364                    ) {
1365                        let query = prompt.input.clone();
1366                        self.update_search_highlights(&query);
1367                    }
1368                } else if let Some(search_state) = &self.search_state {
1369                    let query = search_state.query.clone();
1370                    self.perform_search(&query);
1371                }
1372            }
1373            Action::ToggleSearchConfirmEach => {
1374                self.search_confirm_each = !self.search_confirm_each;
1375                let state = if self.search_confirm_each {
1376                    "enabled"
1377                } else {
1378                    "disabled"
1379                };
1380                self.set_status_message(t!("search.confirm_each_state", state = state).to_string());
1381            }
1382            Action::FileBrowserToggleHidden => {
1383                // Toggle hidden files in file browser (handled via file_open_toggle_hidden)
1384                self.file_open_toggle_hidden();
1385            }
1386            Action::StartMacroRecording => {
1387                // This is a no-op; use ToggleMacroRecording instead
1388                self.set_status_message(
1389                    "Use Ctrl+Shift+R to start recording (will prompt for register)".to_string(),
1390                );
1391            }
1392            Action::StopMacroRecording => {
1393                self.stop_macro_recording();
1394            }
1395            Action::PlayMacro(key) => {
1396                self.play_macro(key);
1397            }
1398            Action::ToggleMacroRecording(key) => {
1399                self.toggle_macro_recording(key);
1400            }
1401            Action::ShowMacro(key) => {
1402                self.show_macro_in_buffer(key);
1403            }
1404            Action::ListMacros => {
1405                self.list_macros_in_buffer();
1406            }
1407            Action::PromptRecordMacro => {
1408                self.start_prompt("Record macro (0-9): ".to_string(), PromptType::RecordMacro);
1409            }
1410            Action::PromptPlayMacro => {
1411                self.start_prompt("Play macro (0-9): ".to_string(), PromptType::PlayMacro);
1412            }
1413            Action::PlayLastMacro => {
1414                if let Some(key) = self.macros.last_register() {
1415                    self.play_macro(key);
1416                } else {
1417                    self.set_status_message(t!("status.no_macro_recorded").to_string());
1418                }
1419            }
1420            Action::PromptSetBookmark => {
1421                self.start_prompt("Set bookmark (0-9): ".to_string(), PromptType::SetBookmark);
1422            }
1423            Action::PromptJumpToBookmark => {
1424                self.start_prompt(
1425                    "Jump to bookmark (0-9): ".to_string(),
1426                    PromptType::JumpToBookmark,
1427                );
1428            }
1429            Action::CompositeNextHunk => {
1430                let buf = self.active_buffer();
1431                self.composite_next_hunk_active(buf);
1432            }
1433            Action::CompositePrevHunk => {
1434                let buf = self.active_buffer();
1435                self.composite_prev_hunk_active(buf);
1436            }
1437            Action::None => {}
1438            Action::DeleteBackward => {
1439                if self.is_editing_disabled() {
1440                    self.set_status_message(t!("buffer.editing_disabled").to_string());
1441                    return Ok(());
1442                }
1443                // Normal backspace handling
1444                if let Some(events) = self.action_to_events(Action::DeleteBackward) {
1445                    if events.len() > 1 {
1446                        // Multi-cursor: use optimized bulk edit (O(n) instead of O(n²))
1447                        let description = "Delete backward".to_string();
1448                        if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description)
1449                        {
1450                            self.active_event_log_mut().append(bulk_edit);
1451                        }
1452                    } else {
1453                        for event in events {
1454                            self.active_event_log_mut().append(event.clone());
1455                            self.apply_event_to_active_buffer(&event);
1456                        }
1457                    }
1458                }
1459            }
1460            Action::PluginAction(action_name) => {
1461                tracing::debug!("handle_action: PluginAction('{}')", action_name);
1462                // Execute the plugin callback via TypeScript plugin thread
1463                // Use non-blocking version to avoid deadlock with async plugin ops
1464                #[cfg(feature = "plugins")]
1465                if let Some(result) = self.plugin_manager.execute_action_async(&action_name) {
1466                    match result {
1467                        Ok(receiver) => {
1468                            // Store pending action for processing in main loop
1469                            self.pending_plugin_actions
1470                                .push((action_name.clone(), receiver));
1471                        }
1472                        Err(e) => {
1473                            self.set_status_message(
1474                                t!("view.plugin_error", error = e.to_string()).to_string(),
1475                            );
1476                            tracing::error!("Plugin action error: {}", e);
1477                        }
1478                    }
1479                } else {
1480                    self.set_status_message(t!("status.plugin_manager_unavailable").to_string());
1481                }
1482                #[cfg(not(feature = "plugins"))]
1483                {
1484                    let _ = action_name;
1485                    self.set_status_message(
1486                        "Plugins not available (compiled without plugin support)".to_string(),
1487                    );
1488                }
1489            }
1490            Action::LoadPluginFromBuffer => {
1491                #[cfg(feature = "plugins")]
1492                {
1493                    let buffer_id = self.active_buffer();
1494                    let state = self.active_state();
1495                    let buffer = &state.buffer;
1496                    let total = buffer.total_bytes();
1497                    let content =
1498                        String::from_utf8_lossy(&buffer.slice_bytes(0..total)).to_string();
1499
1500                    // Determine if TypeScript from file extension, default to TS
1501                    let is_ts = buffer
1502                        .file_path()
1503                        .and_then(|p| p.extension())
1504                        .and_then(|e| e.to_str())
1505                        .map(|e| e == "ts" || e == "tsx")
1506                        .unwrap_or(true);
1507
1508                    // Derive plugin name from buffer filename
1509                    let name = buffer
1510                        .file_path()
1511                        .and_then(|p| p.file_name())
1512                        .and_then(|s| s.to_str())
1513                        .map(|s| s.to_string())
1514                        .unwrap_or_else(|| "buffer-plugin".to_string());
1515
1516                    match self
1517                        .plugin_manager
1518                        .load_plugin_from_source(&content, &name, is_ts)
1519                    {
1520                        Ok(()) => {
1521                            self.set_status_message(format!(
1522                                "Plugin '{}' loaded from buffer",
1523                                name
1524                            ));
1525                        }
1526                        Err(e) => {
1527                            self.set_status_message(format!("Failed to load plugin: {}", e));
1528                            tracing::error!("LoadPluginFromBuffer error: {}", e);
1529                        }
1530                    }
1531
1532                    // Set up plugin dev workspace for LSP support
1533                    self.setup_plugin_dev_lsp(buffer_id, &content);
1534                }
1535                #[cfg(not(feature = "plugins"))]
1536                {
1537                    self.set_status_message(
1538                        "Plugins not available (compiled without plugin support)".to_string(),
1539                    );
1540                }
1541            }
1542            Action::InitReload => {
1543                // Same code path as auto-load: read init.ts and push it
1544                // through the existing plugin pipeline. The runtime's
1545                // hot-reload semantics drop prior commands / handlers /
1546                // event subs / settings before the new source runs.
1547                self.load_init_script(true);
1548                // Re-fire plugins_loaded so handlers expecting a "fresh"
1549                // post-load environment (M2) see it.
1550                self.fire_plugins_loaded_hook();
1551            }
1552            Action::InitEdit => {
1553                // Ensure the file exists (create from template if absent),
1554                // then open it in the editor so users can edit + reload.
1555                let config_dir = self.dir_context.config_dir.clone();
1556                match crate::init_script::ensure_starter(&config_dir) {
1557                    Ok(path) => {
1558                        // Regenerate `types/plugins.d.ts` from the live plugin
1559                        // set. It's written once at editor startup, but any
1560                        // plugin loaded/reloaded/unloaded since then would
1561                        // leave the aggregate stale (or missing, in builds
1562                        // where the plugins feature was off at boot but the
1563                        // user has since enabled a plugin). The user's
1564                        // tsconfig.json lists this file in `files`, so a
1565                        // stale copy is exactly when `getPluginApi("foo")`
1566                        // loses its typed overload.
1567                        let declarations = self.plugin_manager.plugin_declarations();
1568                        crate::init_script::write_plugin_declarations(&config_dir, &declarations);
1569                        match self.open_file(&path) {
1570                            Ok(_) => {
1571                                self.set_status_message(format!("init.ts: {}", path.display()));
1572                            }
1573                            Err(e) => {
1574                                self.set_status_message(format!("init.ts: open failed: {e}"));
1575                            }
1576                        }
1577                    }
1578                    Err(e) => {
1579                        self.set_status_message(format!("init.ts: create failed: {e}"));
1580                    }
1581                }
1582            }
1583            Action::InitCheck => {
1584                // Run the same parse check as `fresh --cmd init check` but
1585                // surface results in the status bar.
1586                let report = crate::init_script::check(&self.dir_context.config_dir);
1587                if report.ok && report.diagnostics.is_empty() {
1588                    self.set_status_message("init.ts: ok".into());
1589                } else if !report.ok {
1590                    let first = report
1591                        .diagnostics
1592                        .first()
1593                        .map(|d| format!("{}:{}: {}", d.line, d.column, d.message))
1594                        .unwrap_or_else(|| "unknown error".into());
1595                    self.set_status_message(format!(
1596                        "init.ts: {} error(s) — first: {first}",
1597                        report.diagnostics.len()
1598                    ));
1599                } else {
1600                    self.set_status_message(format!(
1601                        "init.ts: {} warning(s)",
1602                        report.diagnostics.len()
1603                    ));
1604                }
1605            }
1606            Action::OpenTerminal => {
1607                self.open_terminal();
1608            }
1609            Action::CloseTerminal => {
1610                self.close_terminal();
1611            }
1612            Action::FocusTerminal => {
1613                // If viewing a terminal buffer, switch to terminal mode
1614                if self.is_terminal_buffer(self.active_buffer()) {
1615                    self.terminal_mode = true;
1616                    self.key_context = KeyContext::Terminal;
1617                    self.set_status_message(t!("status.terminal_mode_enabled").to_string());
1618                }
1619            }
1620            Action::TerminalEscape => {
1621                // Exit terminal mode back to editor
1622                if self.terminal_mode {
1623                    self.terminal_mode = false;
1624                    self.key_context = KeyContext::Normal;
1625                    self.set_status_message(t!("status.terminal_mode_disabled").to_string());
1626                }
1627            }
1628            Action::ToggleKeyboardCapture => {
1629                // Toggle keyboard capture mode in terminal
1630                if self.terminal_mode {
1631                    self.keyboard_capture = !self.keyboard_capture;
1632                    if self.keyboard_capture {
1633                        self.set_status_message(
1634                            "Keyboard capture ON - all keys go to terminal (F9 to toggle)"
1635                                .to_string(),
1636                        );
1637                    } else {
1638                        self.set_status_message(
1639                            "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
1640                        );
1641                    }
1642                }
1643            }
1644            Action::TerminalPaste => {
1645                // Paste clipboard contents into terminal as a single batch
1646                if self.terminal_mode {
1647                    if let Some(text) = self.clipboard.paste() {
1648                        self.send_terminal_input(text.as_bytes());
1649                    }
1650                }
1651            }
1652            Action::ShellCommand => {
1653                // Run shell command on buffer/selection, output to new buffer
1654                self.start_shell_command_prompt(false);
1655            }
1656            Action::ShellCommandReplace => {
1657                // Run shell command on buffer/selection, replace content
1658                self.start_shell_command_prompt(true);
1659            }
1660            Action::OpenSettings => {
1661                self.open_settings();
1662            }
1663            Action::CloseSettings => {
1664                // Check if there are unsaved changes
1665                let has_changes = self
1666                    .settings_state
1667                    .as_ref()
1668                    .is_some_and(|s| s.has_changes());
1669                if has_changes {
1670                    // Show confirmation dialog
1671                    if let Some(ref mut state) = self.settings_state {
1672                        state.show_confirm_dialog();
1673                    }
1674                } else {
1675                    self.close_settings(false);
1676                }
1677            }
1678            Action::SettingsSave => {
1679                self.save_settings();
1680            }
1681            Action::SettingsReset => {
1682                if let Some(ref mut state) = self.settings_state {
1683                    state.reset_current_to_default();
1684                }
1685            }
1686            Action::SettingsInherit => {
1687                if let Some(ref mut state) = self.settings_state {
1688                    state.set_current_to_null();
1689                }
1690            }
1691            Action::SettingsToggleFocus => {
1692                if let Some(ref mut state) = self.settings_state {
1693                    state.toggle_focus();
1694                }
1695            }
1696            Action::SettingsActivate => {
1697                self.settings_activate_current();
1698            }
1699            Action::SettingsSearch => {
1700                if let Some(ref mut state) = self.settings_state {
1701                    state.start_search();
1702                }
1703            }
1704            Action::SettingsHelp => {
1705                if let Some(ref mut state) = self.settings_state {
1706                    state.toggle_help();
1707                }
1708            }
1709            Action::SettingsIncrement => {
1710                self.settings_increment_current();
1711            }
1712            Action::SettingsDecrement => {
1713                self.settings_decrement_current();
1714            }
1715            Action::CalibrateInput => {
1716                self.open_calibration_wizard();
1717            }
1718            Action::EventDebug => {
1719                self.open_event_debug();
1720            }
1721            Action::SuspendProcess => {
1722                self.request_suspend();
1723            }
1724            Action::OpenKeybindingEditor => {
1725                self.open_keybinding_editor();
1726            }
1727            Action::PromptConfirm => {
1728                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1729                    use super::prompt_actions::PromptResult;
1730                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1731                        PromptResult::ExecuteAction(action) => {
1732                            return self.handle_action(action);
1733                        }
1734                        PromptResult::EarlyReturn => {
1735                            return Ok(());
1736                        }
1737                        PromptResult::Done => {}
1738                    }
1739                }
1740            }
1741            Action::PromptConfirmWithText(ref text) => {
1742                // For macro playback: set the prompt text before confirming
1743                if let Some(ref mut prompt) = self.prompt {
1744                    prompt.set_input(text.clone());
1745                    self.update_prompt_suggestions();
1746                }
1747                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1748                    use super::prompt_actions::PromptResult;
1749                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1750                        PromptResult::ExecuteAction(action) => {
1751                            return self.handle_action(action);
1752                        }
1753                        PromptResult::EarlyReturn => {
1754                            return Ok(());
1755                        }
1756                        PromptResult::Done => {}
1757                    }
1758                }
1759            }
1760            Action::PopupConfirm => {
1761                use super::popup_actions::PopupConfirmResult;
1762                if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
1763                    return Ok(());
1764                }
1765            }
1766            Action::PopupCancel => {
1767                self.handle_popup_cancel();
1768            }
1769            Action::PopupFocus => {
1770                self.handle_popup_focus();
1771            }
1772            Action::CompletionAccept => {
1773                use super::popup_actions::PopupConfirmResult;
1774                if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
1775                    return Ok(());
1776                }
1777            }
1778            Action::CompletionDismiss => {
1779                self.handle_popup_cancel();
1780            }
1781            Action::InsertChar(c) => {
1782                if self.is_prompting() {
1783                    return self.handle_insert_char_prompt(c);
1784                } else if self.key_context == KeyContext::FileExplorer {
1785                    self.file_explorer_search_push_char(c);
1786                } else {
1787                    self.handle_insert_char_editor(c)?;
1788                }
1789            }
1790            // Prompt clipboard actions
1791            Action::PromptCopy => {
1792                if let Some(prompt) = &self.prompt {
1793                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1794                    if !text.is_empty() {
1795                        self.clipboard.copy(text);
1796                        self.set_status_message(t!("clipboard.copied").to_string());
1797                    }
1798                }
1799            }
1800            Action::PromptCut => {
1801                if let Some(prompt) = &self.prompt {
1802                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1803                    if !text.is_empty() {
1804                        self.clipboard.copy(text);
1805                    }
1806                }
1807                if let Some(prompt) = self.prompt.as_mut() {
1808                    if prompt.has_selection() {
1809                        prompt.delete_selection();
1810                    } else {
1811                        prompt.clear();
1812                    }
1813                }
1814                self.set_status_message(t!("clipboard.cut").to_string());
1815                self.update_prompt_suggestions();
1816            }
1817            Action::PromptPaste => {
1818                if let Some(text) = self.clipboard.paste() {
1819                    if let Some(prompt) = self.prompt.as_mut() {
1820                        prompt.insert_str(&text);
1821                    }
1822                    self.update_prompt_suggestions();
1823                }
1824            }
1825            _ => {
1826                // TODO: Why do we have this catch-all? It seems like actions should either:
1827                // 1. Be handled explicitly above (like InsertChar, PopupConfirm, etc.)
1828                // 2. Or be converted to events consistently
1829                // This catch-all makes it unclear which actions go through event conversion
1830                // vs. direct handling. Consider making this explicit or removing the pattern.
1831                self.apply_action_as_events(action)?;
1832            }
1833        }
1834
1835        Ok(())
1836    }
1837}