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
60 .active_window_mut()
61 .pending_next_key_callbacks
62 .pop_front()
63 {
64 let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
65 self.plugin_manager
66 .read()
67 .unwrap()
68 .resolve_callback(callback_id, json);
69 return true;
70 }
71 if self.active_window_mut().key_capture_active {
72 self.active_window_mut()
73 .pending_key_capture_buffer
74 .push_back(payload);
75 return true;
76 }
77 false
78 }
79}
80
81impl Editor {
82 /// Whether editor-pane popups (LSP completion, hover, signature help,
83 /// global plugin popups, …) should intercept keyboard input.
84 ///
85 /// Returns `false` when:
86 /// - the user has focus on the file explorer pane (popups belong
87 /// to the editor pane, and the explorer must own its own
88 /// keystrokes), or
89 /// - the topmost visible popup is unfocused (LSP popups appear
90 /// unfocused so they don't silently swallow the next keystroke;
91 /// the user grabs focus explicitly with `popup_focus`,
92 /// default `Alt+T`).
93 ///
94 /// Buffer-switch handlers (e.g. `open_file_preview`) clear stale
95 /// popups so a popup tied to the previous preview doesn't follow the
96 /// user across buffers.
97 ///
98 /// Single source of truth for both `get_key_context` (binding resolution)
99 /// and `dispatch_modal_input` (handler routing) so the two cannot drift.
100 pub(crate) fn popups_capture_keys(&self) -> bool {
101 use crate::input::keybindings::KeyContext;
102 use crate::view::popup::PopupResolver;
103 // The workspace-trust prompt is an editor-wide modal shown at startup:
104 // it must own the keyboard regardless of which pane is focused.
105 // Opening a *directory* focuses the file-explorer pane, which would
106 // otherwise short-circuit below and leave the (rendered) prompt
107 // un-interactable.
108 let trust_prompt_up = self
109 .global_popups
110 .top()
111 .is_some_and(|p| p.focused && matches!(p.resolver, PopupResolver::WorkspaceTrust));
112 if trust_prompt_up {
113 return true;
114 }
115 if matches!(self.active_window().key_context, KeyContext::FileExplorer) {
116 return false;
117 }
118 self.topmost_popup_focused()
119 }
120
121 /// Whether the topmost visible popup (global stack first, then the
122 /// active buffer's stack) has been marked focused. Returns `false`
123 /// when no popup is visible — the caller is responsible for
124 /// short-circuiting that case.
125 pub(crate) fn topmost_popup_focused(&self) -> bool {
126 if let Some(popup) = self.global_popups.top() {
127 return popup.focused;
128 }
129 if let Some(popup) = self.active_state().popups.top() {
130 return popup.focused;
131 }
132 // No popup → no capture. Returning `false` here is safe because
133 // every caller gates on visibility before reaching this path.
134 false
135 }
136
137 /// When an *unfocused* popup is on screen, resolve the key event
138 /// against `KeyContext::Popup`/`Global` so the user's bound
139 /// `popup_cancel` (default Esc) and `popup_focus` (default Alt+T)
140 /// keys still take effect even though the popup isn't claiming the
141 /// keyboard. Without this, dismissing an LSP auto-prompt with Esc
142 /// would silently fall through to the buffer.
143 ///
144 /// Returns `None` for any other action so type-to-filter, cursor
145 /// motion, etc. continue to drive the buffer.
146 pub(crate) fn resolve_unfocused_popup_action(
147 &self,
148 event: &crossterm::event::KeyEvent,
149 ) -> Option<crate::input::keybindings::Action> {
150 use crate::input::keybindings::{Action, KeyContext};
151
152 let popup_visible =
153 self.global_popups.is_visible() || self.active_state().popups.is_visible();
154 if !popup_visible || self.topmost_popup_focused() {
155 return None;
156 }
157
158 // Higher-priority modal contexts (Settings, Menu, Prompt) own the
159 // keyboard regardless of whether a buffer popup happens to be
160 // visible underneath. Skip the unfocused-popup interception so
161 // pressing Esc in a settings dialog still closes the dialog
162 // rather than reaching past it to dismiss a stale popup.
163 //
164 // Ask the overlay stack directly rather than re-listing the modal
165 // fields: any layer ranked *above* the popup layer that owns the
166 // keyboard is exactly Settings / Menu / Prompt (the only layers
167 // above Popup). `popup_visible` above guarantees a Popup layer is
168 // present, so `take_while` stops before the editor base layer.
169 let blocked_by_higher_modal = self
170 .overlay_layers()
171 .iter()
172 .take_while(|l| l.kind != crate::app::overlay::LayerKind::Popup)
173 .any(|l| l.owns_keyboard);
174 if blocked_by_higher_modal {
175 return None;
176 }
177
178 let kb = self.keybindings.read().ok()?;
179
180 // `popup_focus` lives in the Normal/FileExplorer context defaults
181 // (not Global) so a user's own binding for the same key in those
182 // contexts wins at the same precedence level. If the resolution
183 // here returns anything other than `PopupFocus`, it's the user's
184 // override — let the normal dispatcher handle it. Don't claim
185 // `popup_cancel` from Normal because Normal's default `Esc`
186 // resolves to `remove_secondary_cursors`, which would shadow the
187 // popup-dismiss intent here.
188 let popup_focus_match = matches!(
189 kb.resolve_in_context_only(event, self.active_window().key_context.clone()),
190 Some(Action::PopupFocus),
191 );
192 if popup_focus_match {
193 return Some(Action::PopupFocus);
194 }
195
196 // Fall back to the Popup context for `popup_cancel`. Esc
197 // (the default `popup_cancel` binding) should still dismiss
198 // an unfocused popup even though the popup itself isn't
199 // claiming the keyboard — that matches every other popup-
200 // dismissal affordance in the editor.
201 let resolved_popup = kb.resolve_in_context_only(event, KeyContext::Popup);
202 match resolved_popup {
203 Some(action @ (Action::PopupCancel | Action::PopupFocus)) => Some(action),
204 _ => None,
205 }
206 }
207
208 /// Resolve a key event against `KeyContext::Completion` when the topmost
209 /// visible popup is a completion popup. Only `CompletionAccept` and
210 /// `CompletionDismiss` are recognised here — every other key falls
211 /// through to the popup's own handler so type-to-filter, navigation, and
212 /// the "any other key dismisses + passthrough" behaviours stay intact.
213 pub(crate) fn resolve_completion_popup_action(
214 &self,
215 event: &crossterm::event::KeyEvent,
216 ) -> Option<crate::input::keybindings::Action> {
217 use crate::input::keybindings::{Action, KeyContext};
218 use crate::view::popup::PopupKind;
219
220 let topmost_kind = if self.global_popups.is_visible() {
221 self.global_popups.top().map(|p| p.kind)
222 } else if self.active_state().popups.is_visible() {
223 self.active_state().popups.top().map(|p| p.kind)
224 } else {
225 None
226 };
227
228 if topmost_kind != Some(PopupKind::Completion) {
229 return None;
230 }
231
232 match self
233 .keybindings
234 .read()
235 .unwrap()
236 .resolve_in_context_only(event, KeyContext::Completion)
237 {
238 Some(action @ (Action::CompletionAccept | Action::CompletionDismiss)) => Some(action),
239 _ => None,
240 }
241 }
242
243 /// Build the editor's overlay stack, ordered top-first (highest
244 /// keyboard-focus precedence first), ending with the always-present
245 /// editor base layer.
246 ///
247 /// This is the single source of truth for overlay precedence: focus
248 /// resolution (`get_key_context`), the unfocused-popup modal guard
249 /// (`resolve_unfocused_popup_action`), the terminal-input gate
250 /// (`dispatch_terminal_input`), and the mouse early-capture ladder
251 /// (`handle_mouse`) all read from this list rather than keeping their
252 /// own conditional ladders.
253 pub(crate) fn overlay_layers(&self) -> Vec<crate::app::overlay::Layer> {
254 use crate::app::overlay::{Layer, LayerKind};
255 use crate::input::keybindings::KeyContext;
256
257 let mut layers = Vec::new();
258
259 // Event-debug dialog intercepts every key event ahead of every
260 // other path (see `handle_key_event`), so it sits at the top of
261 // the stack. Its dispatcher is custom (no `KeyContext`).
262 if self.active_window().is_event_debug_active() {
263 layers.push(Layer {
264 kind: LayerKind::EventDebug,
265 owns_keyboard: true,
266 key_context: None,
267 blocks_terminal_input: true,
268 });
269 }
270 // Full-screen modals own the keyboard whenever they are present.
271 if self.settings_state.as_ref().is_some_and(|s| s.visible) {
272 layers.push(Layer {
273 kind: LayerKind::Settings,
274 owns_keyboard: true,
275 key_context: Some(KeyContext::Settings),
276 blocks_terminal_input: true,
277 });
278 }
279 // Keybinding editor and calibration wizard install their own
280 // input dispatchers (see `input_dispatch.rs`), so they are
281 // transparent to `KeyContext`-driven keybinding resolution
282 // (`key_context: None`) — but they fully own the keyboard while
283 // present and block PTY routing.
284 if self.keybinding_editor.is_some() {
285 layers.push(Layer {
286 kind: LayerKind::KeybindingEditor,
287 owns_keyboard: true,
288 key_context: None,
289 blocks_terminal_input: true,
290 });
291 }
292 if self.calibration_wizard.is_some() {
293 layers.push(Layer {
294 kind: LayerKind::CalibrationWizard,
295 owns_keyboard: true,
296 key_context: None,
297 blocks_terminal_input: true,
298 });
299 }
300 // The workspace-trust prompt is a `global_popups` entry with its
301 // own modal z-band, key handler and mouse handler. When it's the
302 // top of the global stack it takes the place of the generic
303 // `Popup` layer so the dedicated handlers can be reached by
304 // top-down kind dispatch (`handle_mouse`, `input_dispatch`).
305 let trust_on_top = self.global_popups.top().is_some_and(|p| {
306 matches!(
307 p.resolver,
308 crate::view::popup::PopupResolver::WorkspaceTrust
309 )
310 });
311 if trust_on_top {
312 layers.push(Layer {
313 kind: LayerKind::WorkspaceTrust,
314 owns_keyboard: self.popups_capture_keys(),
315 key_context: Some(KeyContext::Popup),
316 blocks_terminal_input: true,
317 });
318 }
319 if self.menu_state.active_menu.is_some() {
320 layers.push(Layer {
321 kind: LayerKind::Menu,
322 owns_keyboard: true,
323 key_context: Some(KeyContext::Menu),
324 blocks_terminal_input: true,
325 });
326 }
327 if self.is_prompting() {
328 // Find/replace prompts resolve in the narrower `SearchPrompt`
329 // context, which owns the match-mode toggles and otherwise falls
330 // through to `Prompt`. Every other prompt stays in `Prompt`, so
331 // the toggle keys (Alt+W etc.) never fire outside an actual search.
332 let key_context = if self.active_prompt_has_search_options() {
333 KeyContext::SearchPrompt
334 } else {
335 KeyContext::Prompt
336 };
337 layers.push(Layer {
338 kind: LayerKind::Prompt,
339 owns_keyboard: true,
340 key_context: Some(key_context),
341 blocks_terminal_input: true,
342 });
343 }
344 // A non-trust popup is *present* whenever visible, but only *owns*
345 // the keyboard while capturing (`popups_capture_keys`); a
346 // merely-visible unfocused popup falls through. Either way a
347 // visible popup blocks PTY routing — it covers the active buffer.
348 if !trust_on_top
349 && (self.global_popups.is_visible() || self.active_state().popups.is_visible())
350 {
351 layers.push(Layer {
352 kind: LayerKind::Popup,
353 owns_keyboard: self.popups_capture_keys(),
354 key_context: Some(KeyContext::Popup),
355 blocks_terminal_input: true,
356 });
357 }
358 // The tab-bar popups (the "+" new-tab menu and the tab right-click
359 // context menu) are modal chrome: while one is open it owns the
360 // keyboard via a custom dispatcher (`handle_new_tab_menu_key` /
361 // `handle_tab_context_menu_key`, run from `handle_key` ahead of
362 // `KeyContext` resolution), so they expose no `KeyContext` here.
363 // Like any covering overlay they block PTY routing — otherwise keys
364 // would leak into an active terminal buffer underneath instead of
365 // driving the menu. Ranked below `Popup` so the unfocused-popup
366 // `take_while` guard above is unaffected.
367 if self.active_window().new_tab_menu.is_some() {
368 layers.push(Layer {
369 kind: LayerKind::NewTabMenu,
370 owns_keyboard: true,
371 key_context: None,
372 blocks_terminal_input: true,
373 });
374 }
375 if self.active_window().tab_context_menu.is_some() {
376 layers.push(Layer {
377 kind: LayerKind::TabContextMenu,
378 owns_keyboard: true,
379 key_context: None,
380 blocks_terminal_input: true,
381 });
382 }
383 // The centered widget modal (picker / new-session form / plugin
384 // overlay) owns the keyboard when focused. It resolves as `Normal`
385 // regardless of the underlying buffer's (possibly stale) context so
386 // mode-keybinding lookups still fire for the panel's own chords.
387 // It blocks PTY routing whenever present — the modal sits on top
388 // of (and obscures) the active terminal buffer.
389 if let Some(f) = self.floating_widget_panel.as_ref() {
390 layers.push(Layer {
391 kind: LayerKind::FloatingModal,
392 owns_keyboard: f.focused,
393 key_context: Some(KeyContext::Normal),
394 blocks_terminal_input: true,
395 });
396 }
397 // The editor-global dock owns the keyboard only while focused; a
398 // blurred dock stays visible but lets the buffer underneath keep
399 // the keyboard *and* receive PTY routing (the dock lives beside
400 // the chrome, not over it).
401 if let Some(d) = self.dock.as_ref() {
402 layers.push(Layer {
403 kind: LayerKind::Dock,
404 owns_keyboard: d.focused,
405 key_context: Some(KeyContext::Dock),
406 blocks_terminal_input: d.focused,
407 });
408 }
409 // The editor content is the keyboard owner of last resort.
410 let base_context = if self
411 .active_window()
412 .is_composite_buffer(self.active_buffer())
413 {
414 KeyContext::CompositeBuffer
415 } else {
416 self.active_window().key_context.clone()
417 };
418 layers.push(Layer {
419 kind: LayerKind::Editor,
420 owns_keyboard: true,
421 key_context: Some(base_context),
422 blocks_terminal_input: false,
423 });
424
425 layers
426 }
427
428 /// True iff any overlay layer is currently blocking key routing to a
429 /// terminal buffer's PTY child. The single source of truth for the
430 /// "is anything modal up?" question.
431 pub(crate) fn presents_blocking_overlay(&self) -> bool {
432 crate::app::overlay::any_layer_blocks_terminal_input(&self.overlay_layers())
433 }
434
435 /// Determine the current keybinding context based on UI state.
436 ///
437 /// Returns the `KeyContext` of the topmost overlay layer that owns the
438 /// keyboard (see [`Editor::overlay_layers`]).
439 pub fn get_key_context(&self) -> crate::input::keybindings::KeyContext {
440 crate::app::overlay::resolve_focus_context(&self.overlay_layers())
441 .expect("editor base layer always owns the keyboard")
442 }
443
444 /// Handle a key event and return whether it was handled
445 /// This is the central key handling logic used by both main.rs and tests
446 pub fn handle_key(
447 &mut self,
448 code: crossterm::event::KeyCode,
449 modifiers: crossterm::event::KeyModifiers,
450 ) -> AnyhowResult<()> {
451 use crate::input::keybindings::Action;
452
453 let _t_total = std::time::Instant::now();
454
455 tracing::trace!(
456 "Editor.handle_key: code={:?}, modifiers={:?}",
457 code,
458 modifiers
459 );
460
461 // Create key event for dispatch methods
462 let key_event = crossterm::event::KeyEvent::new(code, modifiers);
463
464 // Diagnostic for the "dock visible, buffer won't accept keys" wedge
465 // (#2234, item 4): while the dock is mounted, record its host-side focus
466 // plus the active window's key context for *every* key, before any
467 // routing. If a repro shows `dock_focused=true` for keys the user aimed
468 // at the buffer, the dock is swallowing them (line ~492) — a
469 // host-focus / plugin-`dockBlurred` desync; if `dock_focused=false`,
470 // the keys reached the window and the issue is in key-context routing.
471 if let Some(focused) = self.dock.as_ref().map(|d| d.focused) {
472 tracing::debug!(
473 target: "fresh::dock",
474 ?code,
475 dock_focused = focused,
476 key_context = ?self.active_window().key_context,
477 active_window = ?self.active_window_id(),
478 "handle_key: dock mounted (routing diagnostic)"
479 );
480 }
481
482 // Event debug dialog intercepts ALL key events before any other processing.
483 // This must be checked here (not just in main.rs/gui) so it works in
484 // client/server mode where handle_key is called directly.
485 if self.active_window().is_event_debug_active() {
486 self.active_window_mut()
487 .handle_event_debug_input(&key_event);
488 return Ok(());
489 }
490
491 // Try terminal input dispatch first (handles terminal mode and re-entry).
492 // Note: `dispatch_terminal_input` short-circuits to None when a floating
493 // widget panel is mounted, so picker / form keys reach the panel below
494 // instead of being forwarded to the PTY child of the underlying terminal.
495 if self.dispatch_terminal_input(&key_event).is_some() {
496 return Ok(());
497 }
498
499 // If a plugin is awaiting the next keypress (`editor.getNextKey()`),
500 // hand this key to the front-most pending callback and consume it.
501 // This must run before any other dispatch so the awaiting plugin —
502 // typically running a short input loop (flash labels, vi
503 // find-char/replace-char) — can drive its own state machine
504 // without binding every printable key in `defineMode`.
505 if self.try_resolve_next_key_callback(&key_event) {
506 return Ok(());
507 }
508
509 // Floating widget panel claims all keys while visible. Esc
510 // unmounts + fires a `widget_event` "cancel"; smart-key names
511 // (Tab/Return/Backspace/…/Up/Down) route through the widget
512 // command dispatcher; printable chars feed `textInputChar` to
513 // the focused TextInput. Mouse clicks outside the panel are
514 // swallowed (handled in `mouse_input`).
515 // A focused centered modal takes keyboard precedence over the
516 // dock (e.g. the New-Session form opened on top of the dock).
517 if self
518 .floating_widget_panel
519 .as_ref()
520 .is_some_and(|f| f.focused)
521 && self.dispatch_floating_widget_key(super::PanelSlot::Floating, code, modifiers)
522 {
523 return Ok(());
524 }
525 // A focused dock swallows keys in the dispatch below, so the global
526 // focus-toggle (default Alt+O) would never be able to hand focus back
527 // to the editor once you've dived in. Resolve it here, ahead of the
528 // dock's own key handling, so the toggle is symmetric (same key in and
529 // out). Only the blur-out direction needs this early hook — focusing a
530 // blurred/hidden dock is handled by ordinary keybinding resolution
531 // since the editor owns the keyboard in that state.
532 if self.dock.as_ref().is_some_and(|f| f.focused) {
533 let ctx = self.get_key_context();
534 let resolved = self
535 .keybindings
536 .read()
537 .ok()
538 .map(|kb| kb.resolve(&key_event, ctx));
539 if matches!(resolved, Some(Action::ToggleDockFocus)) {
540 self.handle_action(Action::ToggleDockFocus)?;
541 return Ok(());
542 }
543 }
544 if self.dock.as_ref().is_some_and(|f| f.focused)
545 && self.dispatch_floating_widget_key(super::PanelSlot::Dock, code, modifiers)
546 {
547 return Ok(());
548 }
549
550 // Clear skip_ensure_visible flag so cursor becomes visible after key press
551 // (scroll actions will set it again if needed). Use the *effective*
552 // active split so this clears the flag on a focused buffer-group
553 // panel's own view state, not the group host's — without this, a
554 // scroll action in the panel (mouse scrollbar click, plugin
555 // scrollBufferToLine, etc.) sets `skip_ensure_visible` on the panel
556 // and subsequent key presses never clear it, so cursor motion stops
557 // scrolling the viewport.
558 let active_split = self.effective_active_split();
559 if let Some(view_state) = self
560 .windows
561 .get_mut(&self.active_window)
562 .and_then(|w| w.split_view_states_mut())
563 .expect("active window must have a populated split layout")
564 .get_mut(&active_split)
565 {
566 view_state.viewport.clear_skip_ensure_visible();
567 }
568
569 // Dismiss theme info popup on any key press
570 if self.active_window_mut().theme_info_popup.is_some() {
571 self.active_window_mut().theme_info_popup = None;
572 }
573
574 if self
575 .active_window_mut()
576 .file_explorer_context_menu
577 .is_some()
578 {
579 if let Some(result) = self.handle_file_explorer_context_menu_key(code, modifiers) {
580 return result;
581 }
582 }
583
584 // The tab-bar popups (the "+" new-tab menu and the tab right-click
585 // context menu) are modal: while one is open it owns the keyboard so
586 // navigation/selection work and every other key is filtered out
587 // instead of leaking into the active buffer underneath.
588 if let Some(result) = self.handle_new_tab_menu_key(code) {
589 return result;
590 }
591 if let Some(result) = self.handle_tab_context_menu_key(code) {
592 return result;
593 }
594
595 // Determine the current context first
596 let mut context = self.get_key_context();
597
598 // Special case: Hover and Signature Help popups should be dismissed on any key press
599 // EXCEPT for Ctrl+C when the popup has a text selection (allow copy first).
600 //
601 // Fires for both focused and unfocused popups: an unfocused
602 // hover popup that floats over the buffer must still vanish when
603 // the user starts typing — otherwise it lingers indefinitely
604 // because no key event reaches it. The focused-popup path also
605 // covers the legacy case where a transient popup was given
606 // focus (e.g. via the focus-popup keybinding).
607 let popup_visible_on_screen =
608 self.global_popups.is_visible() || self.active_state().popups.is_visible();
609 if popup_visible_on_screen {
610 // Check if the current popup is transient (hover, signature help).
611 // Editor-level popups always take precedence over buffer popups
612 // when both are visible — they're effectively modal overlays.
613 let (is_transient_popup, has_selection) = {
614 let popup = self
615 .global_popups
616 .top()
617 .or_else(|| self.active_state().popups.top());
618 (
619 popup.is_some_and(|p| p.transient),
620 popup.is_some_and(|p| p.has_selection()),
621 )
622 };
623
624 // Don't dismiss if popup has selection and user is pressing Ctrl+C (let them copy first)
625 let is_copy_key = key_event.code == crossterm::event::KeyCode::Char('c')
626 && key_event
627 .modifiers
628 .contains(crossterm::event::KeyModifiers::CONTROL);
629
630 // Skip the dismiss when the user is *transferring* focus to
631 // the popup — otherwise pressing the focus-popup key while
632 // a transient popup is on screen would close the popup
633 // before its handler ever sees the focus action.
634 let resolved_action = self
635 .keybindings
636 .read()
637 .ok()
638 .map(|kb| kb.resolve(&key_event, context.clone()));
639 let is_focus_popup_key = matches!(
640 resolved_action,
641 Some(crate::input::keybindings::Action::PopupFocus)
642 );
643
644 if is_transient_popup && !(has_selection && is_copy_key) && !is_focus_popup_key {
645 // Dismiss the popup on any key press (except Ctrl+C with selection)
646 self.hide_popup();
647 tracing::debug!("Dismissed transient popup on key press");
648 // Recalculate context now that popup is gone
649 context = self.get_key_context();
650 }
651 }
652
653 // Unfocused popup control: even though an unfocused popup
654 // doesn't claim the keyboard, the user's bound popup-cancel
655 // (default Esc) and popup-focus (default Alt+T) keys must
656 // still affect it. Resolved here, *before* the modal
657 // dispatcher routes the key to the buffer/explorer/etc.
658 if let Some(action) = self.resolve_unfocused_popup_action(&key_event) {
659 self.handle_action(action)?;
660 return Ok(());
661 }
662
663 // Try hierarchical modal input dispatch first (Settings, Menu, Prompt, Popup)
664 if self.dispatch_modal_input(&key_event).is_some() {
665 return Ok(());
666 }
667
668 // If a modal was dismissed (e.g., completion popup closed and returned Ignored),
669 // recalculate the context so the key is processed in the correct context.
670 if context != self.get_key_context() {
671 context = self.get_key_context();
672 }
673
674 // Only check buffer mode keybindings when the editor buffer has focus.
675 // FileExplorer, Menu, Prompt, Popup contexts should not trigger mode bindings
676 // (e.g. markdown-source's Enter handler should not fire while the explorer is focused).
677 //
678 // CompositeBuffer is included so a composite buffer's plugin-defined
679 // mode (e.g. the review-diff `diff-view` mode) can bind keys the core
680 // composite handling leaves free — like Enter / Alt+O to open the file
681 // under the cursor. Keys the mode does not bind fall through unchanged
682 // to the composite router and the CompositeBuffer keymap below, so
683 // built-in hunk navigation (n/p/]/[) and close (q) are unaffected.
684 let should_check_mode_bindings = matches!(
685 context,
686 crate::input::keybindings::KeyContext::Normal
687 | crate::input::keybindings::KeyContext::CompositeBuffer
688 );
689
690 if should_check_mode_bindings {
691 // effective_mode() returns buffer-local mode if present, else global mode.
692 // This ensures virtual buffer modes aren't hijacked by global modes.
693 let effective_mode = self.effective_mode().map(|s| s.to_owned());
694
695 if let Some(ref mode_name) = effective_mode {
696 let mode_ctx = crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
697 let key_event = crossterm::event::KeyEvent::new(code, modifiers);
698
699 // Mode chord resolution (via KeybindingResolver)
700 let (chord_result, resolved_action) = {
701 let keybindings = self.keybindings.read().unwrap();
702 let chord_result = keybindings.resolve_chord(
703 &self.active_window().chord_state,
704 &key_event,
705 mode_ctx.clone(),
706 );
707 let resolved = keybindings.resolve(&key_event, mode_ctx);
708 (chord_result, resolved)
709 };
710 match chord_result {
711 crate::input::keybindings::ChordResolution::Complete(action) => {
712 tracing::debug!("Mode chord resolved to action: {:?}", action);
713 self.active_window_mut().chord_state.clear();
714 return self.handle_action(action);
715 }
716 crate::input::keybindings::ChordResolution::Partial => {
717 tracing::debug!("Potential chord prefix in mode '{}'", mode_name);
718 self.active_window_mut().chord_state.push((code, modifiers));
719 return Ok(());
720 }
721 crate::input::keybindings::ChordResolution::NoMatch => {
722 if !self.active_window_mut().chord_state.is_empty() {
723 tracing::debug!("Chord sequence abandoned in mode, clearing state");
724 self.active_window_mut().chord_state.clear();
725 }
726 }
727 }
728
729 // Mode single-key resolution (custom > keymap > plugin defaults)
730 if resolved_action != Action::None {
731 return self.handle_action(resolved_action);
732 }
733 }
734
735 // Handle unbound keys for modes that want to capture input.
736 //
737 // Buffer-local modes with allow_text_input (e.g. search-replace-list)
738 // capture character keys and block other unbound keys.
739 //
740 // Buffer-local modes WITHOUT allow_text_input (e.g. diff-view) let
741 // unbound keys fall through to normal keybinding handling so that
742 // Ctrl+C, arrows, etc. still work.
743 //
744 // Global editor modes (e.g. vi-normal) block all unbound keys when
745 // read-only.
746 if let Some(ref mode_name) = effective_mode {
747 if self.mode_registry.allows_text_input(mode_name) {
748 if let KeyCode::Char(c) = code {
749 let ch = if modifiers.contains(KeyModifiers::SHIFT) {
750 c.to_uppercase().next().unwrap_or(c)
751 } else {
752 c
753 };
754 if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
755 let action_name = format!("mode_text_input:{}", ch);
756 return self.handle_action(Action::PluginAction(action_name));
757 }
758 }
759 // Before blocking the key, resolve it against
760 // the Normal context and forward if it's one of
761 // the clipboard / select-all actions — those
762 // legitimately belong to the focused widget
763 // Text input, not the underlying buffer. Other
764 // Ctrl-modified actions (e.g. Open / Save /
765 // SplitVertical) stay blocked so they don't
766 // hijack a focused search field.
767 let normal_ctx = crate::input::keybindings::KeyContext::Normal;
768 let resolved = {
769 let keybindings = self.keybindings.read().unwrap();
770 keybindings.resolve(&key_event, normal_ctx)
771 };
772 match resolved {
773 Action::Paste | Action::Copy | Action::Cut | Action::SelectAll => {
774 return self.handle_action(resolved);
775 }
776 _ => {}
777 }
778 // Shift+arrow / Ctrl+Shift+arrow extend the
779 // selection on the focused widget TextEdit, if
780 // any. We route these directly here instead of
781 // through the IPC `WidgetAction` path because
782 // selection ops are host-internal — the plugin's
783 // model only cares about the post-`change`
784 // value, which still fires when the selection
785 // is mutated by a subsequent edit.
786 if modifiers.contains(KeyModifiers::SHIFT) {
787 let buffer_id = self.active_buffer();
788 if let Some(panel_id) = self.focused_text_widget_panel_for_buffer(buffer_id)
789 {
790 let ctrl = modifiers.contains(KeyModifiers::CONTROL);
791 let handled = match code {
792 KeyCode::Left if ctrl => self
793 .with_focused_text_editor(&panel_id, |e| {
794 e.move_word_left_selecting()
795 }),
796 KeyCode::Right if ctrl => self
797 .with_focused_text_editor(&panel_id, |e| {
798 e.move_word_right_selecting()
799 }),
800 KeyCode::Left => self.with_focused_text_editor(&panel_id, |e| {
801 e.move_left_selecting()
802 }),
803 KeyCode::Right => self.with_focused_text_editor(&panel_id, |e| {
804 e.move_right_selecting()
805 }),
806 KeyCode::Up => self
807 .with_focused_text_editor(&panel_id, |e| e.move_up_selecting()),
808 KeyCode::Down => self.with_focused_text_editor(&panel_id, |e| {
809 e.move_down_selecting()
810 }),
811 KeyCode::Home => self.with_focused_text_editor(&panel_id, |e| {
812 e.move_home_selecting()
813 }),
814 KeyCode::End => self.with_focused_text_editor(&panel_id, |e| {
815 e.move_end_selecting()
816 }),
817 _ => false,
818 };
819 // We always consume Shift+nav on a
820 // focused widget Text — `handled=false`
821 // means the move was a no-op (e.g.
822 // already at the boundary), which is
823 // still the correct shortcut behaviour.
824 if matches!(
825 code,
826 KeyCode::Left
827 | KeyCode::Right
828 | KeyCode::Up
829 | KeyCode::Down
830 | KeyCode::Home
831 | KeyCode::End
832 ) {
833 let _ = handled;
834 return Ok(());
835 }
836 }
837 }
838 tracing::debug!("Blocking unbound key in text-input mode '{}'", mode_name);
839 return Ok(());
840 }
841 }
842 if let Some(ref mode_name) = self.active_window().editor_mode {
843 if self.mode_registry.is_read_only(mode_name) {
844 tracing::debug!("Ignoring unbound key in read-only mode '{}'", mode_name);
845 return Ok(());
846 }
847 tracing::debug!(
848 "Mode '{}' is not read-only, allowing key through",
849 mode_name
850 );
851 }
852 }
853
854 // --- Composite buffer input routing ---
855 // If the active buffer is a composite buffer (side-by-side diff),
856 // route remaining composite-specific keys (scroll, pane switch, close)
857 // through CompositeInputRouter before falling through to regular
858 // keybinding resolution. Hunk navigation (n/p/]/[) is handled by the
859 // Action system via CompositeBuffer context bindings.
860 {
861 let active_buf = self.active_buffer();
862 let active_split = self.effective_active_split();
863 if self.active_window().is_composite_buffer(active_buf) {
864 if let Some(handled) =
865 self.try_route_composite_key(active_split, active_buf, &key_event)
866 {
867 return handled;
868 }
869 }
870 }
871
872 // Check for chord sequence matches first
873 let key_event = crossterm::event::KeyEvent::new(code, modifiers);
874 let (chord_result, action) = {
875 let keybindings = self.keybindings.read().unwrap();
876 let chord_result = keybindings.resolve_chord(
877 &self.active_window().chord_state,
878 &key_event,
879 context.clone(),
880 );
881 let action = keybindings.resolve(&key_event, context.clone());
882 (chord_result, action)
883 };
884
885 match chord_result {
886 crate::input::keybindings::ChordResolution::Complete(action) => {
887 // Complete chord match - execute action and clear chord state
888 tracing::debug!("Complete chord match -> Action: {:?}", action);
889 self.active_window_mut().chord_state.clear();
890 return self.handle_action(action);
891 }
892 crate::input::keybindings::ChordResolution::Partial => {
893 // Partial match - add to chord state and wait for more keys
894 tracing::debug!("Partial chord match - waiting for next key");
895 self.active_window_mut().chord_state.push((code, modifiers));
896 return Ok(());
897 }
898 crate::input::keybindings::ChordResolution::NoMatch => {
899 // No chord match - clear state and try regular resolution
900 if !self.active_window_mut().chord_state.is_empty() {
901 tracing::debug!("Chord sequence abandoned, clearing state");
902 self.active_window_mut().chord_state.clear();
903 }
904 }
905 }
906
907 // Regular single-key resolution (already resolved above)
908 tracing::trace!("Context: {:?} -> Action: {:?}", context, action);
909
910 // Cancel pending LSP requests on user actions (except LSP actions themselves)
911 // This ensures stale completions don't show up after the user has moved on
912 match action {
913 Action::LspCompletion
914 | Action::LspGotoDefinition
915 | Action::LspReferences
916 | Action::LspImplementation
917 | Action::LspHover
918 | Action::None => {
919 // Don't cancel for LSP actions or no-op
920 }
921 _ => {
922 // Cancel any pending LSP requests
923 self.active_window_mut().cancel_pending_lsp_requests();
924 }
925 }
926
927 // Note: Modal components (Settings, Menu, Prompt, Popup, File Browser) are now
928 // handled by dispatch_modal_input using the InputHandler system.
929 // All remaining actions delegate to handle_action.
930 self.handle_action(action)
931 }
932
933 /// Handle an action (for normal mode and command execution).
934 /// Used by the app module internally and by the GUI module for native menu dispatch.
935 /// Change the current workspace's trust level, persist it, and report it.
936 /// The new policy applies live at the next authority-routed spawn (the
937 /// guarding spawners read the level on every spawn) — there is NO editor
938 /// restart here, deliberately: a rebuild would reset every other
939 /// orchestrator session's buffers/layout (see the body). Trust-gated work
940 /// re-triggers via the `trust_changed` hook instead (e.g. env-manager
941 /// re-activates a now-trusted env). Already-correct selections (e.g.
942 /// confirming the current level) only persist the decision.
943 pub(crate) fn set_workspace_trust_level(
944 &mut self,
945 level: crate::services::workspace_trust::TrustLevel,
946 ) {
947 use crate::services::workspace_trust::TrustLevel;
948 // Trust is a per-window gate: each `Window` owns its own authority +
949 // `WorkspaceTrust` (issue #2280), and the guarding spawners read the
950 // level live at spawn time. Writing the new level here is the whole
951 // change — `set_level` itself documents "no rebuild required". The
952 // next authority-routed spawn (LSP, terminal command, task, formatter,
953 // plugin `spawnProcess`) honours the new level automatically.
954 //
955 // We deliberately do NOT `request_restart` here: that tears down and
956 // rebuilds the *entire* editor — every orchestrator session window,
957 // not just this one — which discarded other sessions' buffers and
958 // reset the layout when toggling a single session's trust (the
959 // trust-level-modal reset bug).
960 let trust = &self.authority().workspace_trust;
961 trust.set_level(level);
962 let msg = match level {
963 TrustLevel::Trusted => t!("trust.now_trusted"),
964 TrustLevel::Restricted => t!("trust.now_restricted"),
965 TrustLevel::Blocked => t!("trust.now_blocked"),
966 }
967 .to_string();
968 self.active_window_mut().status_message = Some(msg);
969
970 // Refresh the plugin-visible state snapshot so `editor.workspaceTrustLevel()`
971 // reflects the new level, then notify plugins. The `trust_changed` hook
972 // lets trust-gated work re-trigger inline — env-manager re-activates a
973 // now-trusted env without a window switch — and it is the single signal
974 // every trust-change path (modal confirm, status pill, plugin action)
975 // funnels through, since they all route here. Deliberately a hook and a
976 // snapshot refresh, NOT a `request_restart`: a rebuild would reset every
977 // other session's buffers/layout (see the note above).
978 #[cfg(feature = "plugins")]
979 {
980 self.update_plugin_state_snapshot();
981 self.plugin_manager.read().unwrap().run_hook(
982 "trust_changed",
983 crate::services::plugins::hooks::HookArgs::TrustChanged {
984 level: level.as_str().to_string(),
985 },
986 );
987 }
988 }
989
990 pub(crate) fn handle_action(&mut self, action: Action) -> AnyhowResult<()> {
991 use crate::input::keybindings::Action;
992
993 // Record action to macro if recording
994 self.record_macro_action(&action);
995
996 // Reset dabbrev cycling session on any non-dabbrev action.
997 if !matches!(action, Action::DabbrevExpand) {
998 self.reset_dabbrev_state();
999 }
1000
1001 match action {
1002 Action::Quit => self.quit(),
1003 Action::ForceQuit => {
1004 self.should_quit = true;
1005 }
1006 Action::Detach => {
1007 self.should_detach = true;
1008 }
1009 Action::WorkspaceTrustTrust => {
1010 self.set_workspace_trust_level(
1011 crate::services::workspace_trust::TrustLevel::Trusted,
1012 );
1013 }
1014 Action::WorkspaceTrustRestrict => {
1015 self.set_workspace_trust_level(
1016 crate::services::workspace_trust::TrustLevel::Restricted,
1017 );
1018 }
1019 Action::WorkspaceTrustBlock => {
1020 self.set_workspace_trust_level(
1021 crate::services::workspace_trust::TrustLevel::Blocked,
1022 );
1023 }
1024 Action::WorkspaceTrustPrompt => {
1025 // Voluntarily-opened: cancellable (Esc / Cancel just closes).
1026 self.show_workspace_trust_popup(true);
1027 }
1028 Action::Save => {
1029 // Check if buffer has a file path - if not, redirect to SaveAs
1030 if self.active_state().buffer.file_path().is_none() {
1031 self.start_prompt_with_initial_text(
1032 t!("file.save_as_prompt").to_string(),
1033 PromptType::SaveFileAs,
1034 String::new(),
1035 );
1036 self.init_file_open_state();
1037 } else if self.check_save_conflict().is_some() {
1038 // Check if file was modified externally since we opened/saved it
1039 self.start_prompt(
1040 t!("file.file_changed_prompt").to_string(),
1041 PromptType::ConfirmSaveConflict,
1042 );
1043 } else if let Err(e) = self.save() {
1044 let msg = format!("{}", e);
1045 self.active_window_mut().status_message =
1046 Some(t!("file.save_failed", error = &msg).to_string());
1047 }
1048 }
1049 Action::SaveAs => {
1050 // Get current filename as default suggestion
1051 let current_path = self
1052 .active_state()
1053 .buffer
1054 .file_path()
1055 .map(|p| {
1056 // Make path relative to working_dir if possible
1057 p.strip_prefix(self.working_dir())
1058 .unwrap_or(p)
1059 .to_string_lossy()
1060 .to_string()
1061 })
1062 .unwrap_or_default();
1063 self.start_prompt_with_initial_text(
1064 t!("file.save_as_prompt").to_string(),
1065 PromptType::SaveFileAs,
1066 current_path,
1067 );
1068 self.init_file_open_state();
1069 }
1070 Action::Open => {
1071 self.start_prompt(t!("file.open_prompt").to_string(), PromptType::OpenFile);
1072 self.prefill_open_file_prompt();
1073 self.init_file_open_state();
1074 }
1075 Action::SwitchProject => {
1076 self.start_prompt(
1077 t!("file.switch_project_prompt").to_string(),
1078 PromptType::SwitchProject,
1079 );
1080 self.init_folder_open_state();
1081 }
1082 Action::GotoLine => {
1083 let has_line_index = self
1084 .buffers()
1085 .get(&self.active_buffer())
1086 .is_none_or(|s| s.buffer.line_count().is_some());
1087 if has_line_index {
1088 self.start_prompt(
1089 t!("file.goto_line_prompt").to_string(),
1090 PromptType::GotoLine,
1091 );
1092 } else {
1093 self.start_prompt(
1094 t!("goto.scan_confirm_prompt", yes = "y", no = "N").to_string(),
1095 PromptType::GotoLineScanConfirm,
1096 );
1097 }
1098 }
1099 Action::ScanLineIndex => {
1100 self.start_incremental_line_scan(false);
1101 }
1102 Action::New => {
1103 self.new_buffer();
1104 }
1105 Action::Close | Action::CloseTab => {
1106 // Both Close and CloseTab use close_tab() which handles:
1107 // - Closing the split if this is the last buffer and there are other splits
1108 // - Prompting for unsaved changes
1109 // - Properly closing the buffer
1110 self.close_tab();
1111 }
1112 Action::Revert => {
1113 // Check if buffer has unsaved changes - prompt for confirmation
1114 if self.active_state().buffer.is_modified() {
1115 let revert_key = t!("prompt.key.revert").to_string();
1116 let cancel_key = t!("prompt.key.cancel").to_string();
1117 self.start_prompt(
1118 t!(
1119 "prompt.revert_confirm",
1120 revert_key = revert_key,
1121 cancel_key = cancel_key
1122 )
1123 .to_string(),
1124 PromptType::ConfirmRevert,
1125 );
1126 } else {
1127 // No local changes, just revert
1128 if let Err(e) = self.revert_file() {
1129 self.set_status_message(
1130 t!("error.failed_to_revert", error = e.to_string()).to_string(),
1131 );
1132 }
1133 }
1134 }
1135 Action::ToggleAutoRevert => {
1136 self.toggle_auto_revert();
1137 }
1138 Action::FormatBuffer => {
1139 if let Err(e) = self.format_buffer() {
1140 self.set_status_message(
1141 t!("error.format_failed", error = e.to_string()).to_string(),
1142 );
1143 }
1144 }
1145 Action::TrimTrailingWhitespace => match self.trim_trailing_whitespace() {
1146 Ok(true) => {
1147 self.set_status_message(t!("whitespace.trimmed").to_string());
1148 }
1149 Ok(false) => {
1150 self.set_status_message(t!("whitespace.no_trailing").to_string());
1151 }
1152 Err(e) => {
1153 self.set_status_message(
1154 t!("error.trim_whitespace_failed", error = e).to_string(),
1155 );
1156 }
1157 },
1158 Action::EnsureFinalNewline => match self.ensure_final_newline() {
1159 Ok(true) => {
1160 self.set_status_message(t!("whitespace.newline_added").to_string());
1161 }
1162 Ok(false) => {
1163 self.set_status_message(t!("whitespace.already_has_newline").to_string());
1164 }
1165 Err(e) => {
1166 self.set_status_message(
1167 t!("error.ensure_newline_failed", error = e).to_string(),
1168 );
1169 }
1170 },
1171 Action::Copy => {
1172 // Editor-level popups take precedence over everything, including the file explorer.
1173 let popup = self
1174 .global_popups
1175 .top()
1176 .or_else(|| self.active_state().popups.top());
1177 if let Some(popup) = popup {
1178 if popup.has_selection() {
1179 if let Some(text) = popup.get_selected_text() {
1180 self.clipboard.copy(text);
1181 self.set_status_message(t!("clipboard.copied").to_string());
1182 return Ok(());
1183 }
1184 }
1185 }
1186 if self.active_window_mut().key_context
1187 == crate::input::keybindings::KeyContext::FileExplorer
1188 {
1189 self.active_window_mut().file_explorer_copy();
1190 return Ok(());
1191 }
1192 // A focused widget Text input on the active buffer
1193 // wins over the underlying buffer's copy path. The
1194 // widget's selection lives in its TextEdit; this
1195 // bypasses `is_editing_disabled` because widget
1196 // inputs are independent of the underlying virtual
1197 // buffer's read-only-ness.
1198 let buffer_id = self.active_buffer();
1199 if let Some(panel_id) = self.focused_text_widget_panel_for_buffer(buffer_id) {
1200 if self.handle_widget_copy(&panel_id) {
1201 self.set_status_message(t!("clipboard.copied").to_string());
1202 return Ok(());
1203 }
1204 }
1205 // Check if active buffer is a composite buffer
1206 if self.active_window().is_composite_buffer(buffer_id) {
1207 if let Some(_handled) = self.handle_composite_action(buffer_id, &Action::Copy) {
1208 return Ok(());
1209 }
1210 }
1211 self.copy_selection()
1212 }
1213 Action::CopyWithTheme(theme) => self.copy_selection_with_theme(&theme),
1214 Action::CopyFilePath => self.copy_active_buffer_path(false),
1215 Action::CopyRelativeFilePath => self.copy_active_buffer_path(true),
1216 Action::Cut => {
1217 if self.active_window_mut().key_context
1218 == crate::input::keybindings::KeyContext::FileExplorer
1219 {
1220 self.active_window_mut().file_explorer_cut();
1221 return Ok(());
1222 }
1223 // Focused widget Text wins over the buffer cut path,
1224 // and bypasses `is_editing_disabled` — widget inputs
1225 // are independent of the underlying virtual buffer.
1226 let buffer_id = self.active_buffer();
1227 if let Some(panel_id) = self.focused_text_widget_panel_for_buffer(buffer_id) {
1228 if self.handle_widget_cut(&panel_id) {
1229 return Ok(());
1230 }
1231 }
1232 if self.active_window().is_editing_disabled() {
1233 self.set_status_message(t!("buffer.editing_disabled").to_string());
1234 return Ok(());
1235 }
1236 self.cut_selection()
1237 }
1238 Action::Paste => {
1239 if self.active_window_mut().key_context
1240 == crate::input::keybindings::KeyContext::FileExplorer
1241 {
1242 self.file_explorer_paste();
1243 return Ok(());
1244 }
1245 // Focused widget Text wins over the buffer paste
1246 // path, and bypasses `is_editing_disabled`. Line
1247 // endings get normalised to LF before insertion
1248 // (multi-line `TextEdit` stores plain `\n`;
1249 // single-line strips them).
1250 let buffer_id = self.active_buffer();
1251 if let Some(panel_id) = self.focused_text_widget_panel_for_buffer(buffer_id) {
1252 if let Some(text) = self.clipboard.paste() {
1253 let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
1254 self.handle_widget_insert_str(&panel_id, &normalized);
1255 self.set_status_message(t!("clipboard.pasted").to_string());
1256 }
1257 return Ok(());
1258 }
1259 if self.active_window().is_editing_disabled() {
1260 self.set_status_message(t!("buffer.editing_disabled").to_string());
1261 return Ok(());
1262 }
1263 self.paste()
1264 }
1265 Action::SelectAll => {
1266 // Focused widget Text wins over the buffer's
1267 // select-all. SelectAll on the buffer is then
1268 // handled by the default `apply_action_as_events`
1269 // catch-all path below.
1270 let buffer_id = self.active_buffer();
1271 if let Some(panel_id) = self.focused_text_widget_panel_for_buffer(buffer_id) {
1272 self.handle_widget_select_all(&panel_id);
1273 return Ok(());
1274 }
1275 self.apply_action_as_events(Action::SelectAll)?;
1276 }
1277 Action::YankWordForward => self.yank_word_forward(),
1278 Action::YankWordBackward => self.yank_word_backward(),
1279 Action::YankToLineEnd => self.yank_to_line_end(),
1280 Action::YankToLineStart => self.yank_to_line_start(),
1281 Action::YankViWordEnd => self.yank_vi_word_end(),
1282 Action::Undo => {
1283 self.handle_undo();
1284 }
1285 Action::Redo => {
1286 self.handle_redo();
1287 }
1288 Action::ShowHelp => {
1289 self.ensure_help_panel_mode_registered();
1290 self.active_window_mut().open_help_manual();
1291 }
1292 Action::ShowKeyboardShortcuts => {
1293 self.ensure_help_panel_mode_registered();
1294 self.active_window_mut().open_keyboard_shortcuts();
1295 }
1296 Action::ShowWarnings => {
1297 self.show_warnings_popup();
1298 }
1299 Action::ShowStatusLog => {
1300 self.open_status_log();
1301 }
1302 Action::ShowLspStatus => {
1303 self.show_lsp_status_popup();
1304 }
1305 Action::ShowRemoteIndicatorMenu => {
1306 self.show_remote_indicator_popup();
1307 }
1308 Action::ShowReadOnlyMenu => {
1309 self.show_read_only_popup();
1310 }
1311 Action::ClearWarnings => {
1312 self.active_window_mut().clear_warnings();
1313 }
1314 Action::CommandPalette => {
1315 // CommandPalette now delegates to QuickOpen (which starts with ">" prefix
1316 // for command mode). Toggle if already open.
1317 if self.close_quick_open_if_open() {
1318 return Ok(());
1319 }
1320 self.start_quick_open();
1321 }
1322 Action::QuickOpen => {
1323 if self.close_quick_open_if_open() {
1324 return Ok(());
1325 }
1326 self.start_quick_open();
1327 }
1328 Action::QuickOpenBuffers => {
1329 if self.close_quick_open_if_open() {
1330 return Ok(());
1331 }
1332 self.start_quick_open_with_prefix("#");
1333 }
1334 Action::QuickOpenFiles => {
1335 if self.close_quick_open_if_open() {
1336 return Ok(());
1337 }
1338 self.start_quick_open_with_prefix("");
1339 }
1340 Action::OpenLiveGrep => {
1341 self.handle_action(Action::PluginAction("start_live_grep".to_string()))?;
1342 }
1343 Action::ResumeLiveGrep => {
1344 self.handle_action(Action::PluginAction("resume_live_grep".to_string()))?;
1345 }
1346 Action::ToggleUtilityDock => {
1347 use crate::view::split::SplitRole;
1348 if let Some(dock_leaf) = self
1349 .windows
1350 .get(&self.active_window)
1351 .and_then(|w| w.buffers.splits())
1352 .map(|(mgr, _)| mgr)
1353 .expect("active window must have a populated split layout")
1354 .find_leaf_by_role(SplitRole::UtilityDock)
1355 {
1356 let active = self
1357 .windows
1358 .get(&self.active_window)
1359 .and_then(|w| w.buffers.splits())
1360 .map(|(mgr, _)| mgr)
1361 .expect("active window must have a populated split layout")
1362 .active_split();
1363 if active == dock_leaf {
1364 // Already focused — no editor-leaf history yet,
1365 // so just cycle to the next leaf via the
1366 // existing Alt+] command. Phase 7 will track a
1367 // proper "previous editor split" pointer.
1368 self.next_split();
1369 } else {
1370 self.windows
1371 .get_mut(&self.active_window)
1372 .and_then(|w| w.split_manager_mut())
1373 .expect("active window must have a populated split layout")
1374 .set_active_split(dock_leaf);
1375 }
1376 } else {
1377 self.set_status_message(
1378 "No Utility Dock open — invoke a dock-aware utility (Diagnostics, Search/Replace, …)"
1379 .to_string(),
1380 );
1381 }
1382 }
1383 Action::CycleLiveGrepProvider => {
1384 // Only meaningful while the Live Grep overlay is open. Detect via prompt state —
1385 // both `PromptType::LiveGrep` (Resume's pre-seeded overlay) and
1386 // `Plugin{custom_type:"live-grep"}` (the live-running plugin's prompt) qualify.
1387 let in_live_grep = self
1388 .active_window()
1389 .prompt
1390 .as_ref()
1391 .map(|p| match &p.prompt_type {
1392 PromptType::LiveGrep => true,
1393 PromptType::Plugin { custom_type } => custom_type == "live-grep",
1394 _ => false,
1395 })
1396 .unwrap_or(false);
1397 if !in_live_grep {
1398 self.set_status_message(
1399 "Cycle Live Grep provider only works inside Live Grep".to_string(),
1400 );
1401 return Ok(());
1402 }
1403 self.handle_action(Action::PluginAction("live_grep_cycle_provider".to_string()))?;
1404 }
1405 Action::OpenTerminalInDock => {
1406 self.handle_open_terminal_in_dock()?;
1407 }
1408 Action::ToggleLineWrap => {
1409 let new_value = !self.config.editor.line_wrap;
1410 self.config_mut().editor.line_wrap = new_value;
1411 // `resolve_line_wrap_for_buffer` below reads
1412 // `Window::config()`, which holds a *separate* `Arc<Config>`
1413 // clone from the Editor's. Without this sync the resolve
1414 // would return the pre-toggle value and we'd write the
1415 // *old* line-wrap state back into the viewport — silently
1416 // no-op'ing the toggle while still flipping the status
1417 // message. See `Editor::config_mut` for the broader rule.
1418 self.sync_windows_config();
1419
1420 // Update all viewports to reflect the new line wrap setting,
1421 // respecting per-language overrides
1422 let leaf_ids: Vec<_> = self
1423 .windows
1424 .get(&self.active_window)
1425 .and_then(|w| w.buffers.splits())
1426 .map(|(_, vs)| vs)
1427 .expect("active window must have a populated split layout")
1428 .keys()
1429 .copied()
1430 .collect();
1431 for leaf_id in leaf_ids {
1432 let buffer_id = self
1433 .split_manager_mut()
1434 .get_buffer_id(leaf_id.into())
1435 .unwrap_or(BufferId(0));
1436 let effective_wrap =
1437 self.active_window().resolve_line_wrap_for_buffer(buffer_id);
1438 let wrap_column = self
1439 .active_window()
1440 .resolve_wrap_column_for_buffer(buffer_id);
1441 if let Some(view_state) = self
1442 .windows
1443 .get_mut(&self.active_window)
1444 .and_then(|w| w.split_view_states_mut())
1445 .expect("active window must have a populated split layout")
1446 .get_mut(&leaf_id)
1447 {
1448 view_state.viewport.line_wrap_enabled = effective_wrap;
1449 view_state.viewport.wrap_indent = self.config.editor.wrap_indent;
1450 view_state.viewport.wrap_column = wrap_column;
1451 // Global toggle expresses global intent; drop the
1452 // per-buffer pin so it doesn't revert this change.
1453 view_state.line_wrap_override = None;
1454 }
1455 }
1456
1457 let state = if self.config.editor.line_wrap {
1458 t!("view.state_enabled").to_string()
1459 } else {
1460 t!("view.state_disabled").to_string()
1461 };
1462 self.set_status_message(t!("view.line_wrap_state", state = state).to_string());
1463 }
1464 Action::ToggleCurrentLineHighlight => {
1465 let new_value = !self.config.editor.highlight_current_line;
1466 self.config_mut().editor.highlight_current_line = new_value;
1467
1468 // Update all splits
1469 let leaf_ids: Vec<_> = self
1470 .windows
1471 .get(&self.active_window)
1472 .and_then(|w| w.buffers.splits())
1473 .map(|(_, vs)| vs)
1474 .expect("active window must have a populated split layout")
1475 .keys()
1476 .copied()
1477 .collect();
1478 for leaf_id in leaf_ids {
1479 if let Some(view_state) = self
1480 .windows
1481 .get_mut(&self.active_window)
1482 .and_then(|w| w.split_view_states_mut())
1483 .expect("active window must have a populated split layout")
1484 .get_mut(&leaf_id)
1485 {
1486 view_state.highlight_current_line =
1487 self.config.editor.highlight_current_line;
1488 }
1489 }
1490
1491 let state = if self.config.editor.highlight_current_line {
1492 t!("view.state_enabled").to_string()
1493 } else {
1494 t!("view.state_disabled").to_string()
1495 };
1496 self.set_status_message(
1497 t!("view.current_line_highlight_state", state = state).to_string(),
1498 );
1499 }
1500 Action::ToggleOccurrenceHighlight => {
1501 let new_value = !self.config.editor.highlight_occurrences;
1502 self.config_mut().editor.highlight_occurrences = new_value;
1503
1504 // Update all open buffers
1505 for window in self.windows.values_mut() {
1506 for (_, state) in &mut window.buffers {
1507 state.reference_highlight_overlay.enabled = new_value;
1508 if !new_value {
1509 state
1510 .reference_highlight_overlay
1511 .clear(&mut state.overlays, &mut state.marker_list);
1512 }
1513 }
1514 }
1515
1516 let state = if new_value {
1517 t!("view.state_enabled").to_string()
1518 } else {
1519 t!("view.state_disabled").to_string()
1520 };
1521 self.set_status_message(
1522 t!("view.occurrence_highlight_state", state = state).to_string(),
1523 );
1524 }
1525 Action::ToggleReadOnly => {
1526 let buffer_id = self.active_buffer();
1527 let is_now_read_only = self
1528 .active_window()
1529 .buffer_metadata
1530 .get(&buffer_id)
1531 .map(|m| !m.read_only)
1532 .unwrap_or(false);
1533 self.active_window_mut()
1534 .mark_buffer_read_only(buffer_id, is_now_read_only);
1535
1536 let state_str = if is_now_read_only {
1537 t!("view.state_enabled").to_string()
1538 } else {
1539 t!("view.state_disabled").to_string()
1540 };
1541 self.set_status_message(t!("view.read_only_state", state = state_str).to_string());
1542 }
1543 Action::TogglePageView => {
1544 self.active_window_mut().handle_toggle_page_view();
1545 }
1546 Action::SetPageWidth => {
1547 let active_split = self
1548 .windows
1549 .get(&self.active_window)
1550 .and_then(|w| w.buffers.splits())
1551 .map(|(mgr, _)| mgr)
1552 .expect("active window must have a populated split layout")
1553 .active_split();
1554 let current = self
1555 .windows
1556 .get(&self.active_window)
1557 .and_then(|w| w.buffers.splits())
1558 .map(|(_, vs)| vs)
1559 .expect("active window must have a populated split layout")
1560 .get(&active_split)
1561 .and_then(|v| v.compose_width.map(|w| w.to_string()))
1562 .unwrap_or_default();
1563 self.start_prompt_with_initial_text(
1564 "Page width (empty = viewport): ".to_string(),
1565 PromptType::SetPageWidth,
1566 current,
1567 );
1568 }
1569 Action::SetBackground => {
1570 let default_path = self
1571 .ansi_background_path
1572 .as_ref()
1573 .and_then(|p| {
1574 p.strip_prefix(self.working_dir())
1575 .ok()
1576 .map(|rel| rel.to_string_lossy().to_string())
1577 })
1578 .unwrap_or_else(|| DEFAULT_BACKGROUND_FILE.to_string());
1579
1580 self.start_prompt_with_initial_text(
1581 "Background file: ".to_string(),
1582 PromptType::SetBackgroundFile,
1583 default_path,
1584 );
1585 }
1586 Action::SetBackgroundBlend => {
1587 let default_amount = format!("{:.2}", self.background_fade);
1588 self.start_prompt_with_initial_text(
1589 "Background blend (0-1): ".to_string(),
1590 PromptType::SetBackgroundBlend,
1591 default_amount,
1592 );
1593 }
1594 Action::LspCompletion => {
1595 self.request_completion();
1596 }
1597 Action::DabbrevExpand => {
1598 self.dabbrev_expand();
1599 }
1600 Action::LspGotoDefinition => {
1601 self.request_goto_definition()?;
1602 }
1603 Action::LspRename => {
1604 self.start_rename()?;
1605 }
1606 Action::LspHover => {
1607 self.request_hover()?;
1608 }
1609 Action::LspReferences => {
1610 self.request_references()?;
1611 }
1612 Action::LspImplementation => {
1613 self.request_implementation()?;
1614 }
1615 Action::LspSignatureHelp => {
1616 self.request_signature_help();
1617 }
1618 Action::LspCodeActions => {
1619 self.request_code_actions()?;
1620 }
1621 Action::LspRestart => {
1622 self.handle_lsp_restart();
1623 }
1624 Action::LspStop => {
1625 self.handle_lsp_stop();
1626 }
1627 Action::LspToggleForBuffer => {
1628 self.handle_lsp_toggle_for_buffer();
1629 }
1630 Action::ToggleInlayHints => {
1631 self.toggle_inlay_hints();
1632 }
1633 Action::DumpConfig => {
1634 self.dump_config();
1635 }
1636 Action::RedrawScreen => {
1637 self.request_full_redraw();
1638 }
1639 Action::SelectTheme => {
1640 self.start_select_theme_prompt();
1641 }
1642 Action::InspectThemeAtCursor => {
1643 self.inspect_theme_at_cursor();
1644 }
1645 Action::SelectKeybindingMap => {
1646 self.start_select_keybinding_map_prompt();
1647 }
1648 Action::SelectCursorStyle => {
1649 self.start_select_cursor_style_prompt();
1650 }
1651 Action::SelectLocale => {
1652 self.start_select_locale_prompt();
1653 }
1654 Action::Search => {
1655 // If already in a search-related prompt, Ctrl+F acts like Enter (confirm search)
1656 let is_search_prompt = self.active_window().prompt.as_ref().is_some_and(|p| {
1657 matches!(
1658 p.prompt_type,
1659 PromptType::Search
1660 | PromptType::ReplaceSearch
1661 | PromptType::QueryReplaceSearch
1662 )
1663 });
1664
1665 if is_search_prompt {
1666 self.confirm_prompt();
1667 } else {
1668 self.start_search_prompt(
1669 t!("file.search_prompt").to_string(),
1670 PromptType::Search,
1671 false,
1672 );
1673 }
1674 }
1675 Action::Replace => {
1676 // Use same flow as query-replace, just with confirm_each defaulting to false
1677 self.start_search_prompt(
1678 t!("file.replace_prompt").to_string(),
1679 PromptType::ReplaceSearch,
1680 false,
1681 );
1682 }
1683 Action::QueryReplace => {
1684 // Enable confirm mode by default for query-replace
1685 self.active_window_mut().search_confirm_each = true;
1686 self.start_search_prompt(
1687 "Query replace: ".to_string(),
1688 PromptType::QueryReplaceSearch,
1689 false,
1690 );
1691 }
1692 Action::FindInSelection => {
1693 self.start_search_prompt(
1694 t!("file.search_prompt").to_string(),
1695 PromptType::Search,
1696 true,
1697 );
1698 }
1699 Action::FindNext => {
1700 self.find_next();
1701 }
1702 Action::FindPrevious => {
1703 self.find_previous();
1704 }
1705 Action::FindSelectionNext => {
1706 self.find_selection_next();
1707 }
1708 Action::FindSelectionPrevious => {
1709 self.find_selection_previous();
1710 }
1711 Action::ClearSearch => {
1712 self.active_window_mut().clear_search_highlights();
1713 }
1714 Action::AddCursorNextMatch => self.add_cursor_at_next_match(),
1715 Action::AddCursorAbove => self.add_cursor_above(),
1716 Action::AddCursorBelow => self.add_cursor_below(),
1717 Action::AddCursorsToLineEnds => self.add_cursors_to_line_ends(),
1718 Action::NextBuffer => self.next_buffer(),
1719 Action::PrevBuffer => self.prev_buffer(),
1720 Action::SwitchToPreviousTab => self.switch_to_previous_tab(),
1721 Action::SwitchToTabByName => self.start_switch_to_tab_prompt(),
1722
1723 // Tab scrolling (manual scroll - don't auto-adjust)
1724 Action::ScrollTabsLeft => {
1725 let active_split_id = self
1726 .windows
1727 .get(&self.active_window)
1728 .and_then(|w| w.buffers.splits())
1729 .map(|(mgr, _)| mgr)
1730 .expect("active window must have a populated split layout")
1731 .active_split();
1732 if let Some(view_state) = self
1733 .windows
1734 .get_mut(&self.active_window)
1735 .and_then(|w| w.split_view_states_mut())
1736 .expect("active window must have a populated split layout")
1737 .get_mut(&active_split_id)
1738 {
1739 view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_sub(5);
1740 self.set_status_message(t!("status.scrolled_tabs_left").to_string());
1741 }
1742 }
1743 Action::ScrollTabsRight => {
1744 let active_split_id = self
1745 .windows
1746 .get(&self.active_window)
1747 .and_then(|w| w.buffers.splits())
1748 .map(|(mgr, _)| mgr)
1749 .expect("active window must have a populated split layout")
1750 .active_split();
1751 if let Some(view_state) = self
1752 .windows
1753 .get_mut(&self.active_window)
1754 .and_then(|w| w.split_view_states_mut())
1755 .expect("active window must have a populated split layout")
1756 .get_mut(&active_split_id)
1757 {
1758 view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_add(5);
1759 self.set_status_message(t!("status.scrolled_tabs_right").to_string());
1760 }
1761 }
1762 Action::NavigateBack => self.navigate_back(),
1763 Action::NavigateForward => self.navigate_forward(),
1764 Action::SplitHorizontal => self.split_pane_horizontal(),
1765 Action::SplitVertical => self.split_pane_vertical(),
1766 Action::CloseSplit => self.close_active_split(),
1767 Action::NextSplit => self.next_split(),
1768 Action::PrevSplit => self.prev_split(),
1769 Action::NextWindow => self.next_window(),
1770 Action::PrevWindow => self.prev_window(),
1771 Action::IncreaseSplitSize => self.adjust_split_size(0.05),
1772 Action::DecreaseSplitSize => self.adjust_split_size(-0.05),
1773 Action::ToggleMaximizeSplit => self.toggle_maximize_split(),
1774 Action::ToggleFileExplorer => self.toggle_file_explorer(),
1775 Action::ToggleFileExplorerSide => self.toggle_file_explorer_side(),
1776 Action::ToggleMenuBar => self.toggle_menu_bar(),
1777 Action::ToggleTabBar => self.active_window_mut().toggle_tab_bar(),
1778 Action::ToggleStatusBar => self.active_window_mut().toggle_status_bar(),
1779 Action::TogglePromptLine => self.active_window_mut().toggle_prompt_line(),
1780 Action::ToggleVerticalScrollbar => self.toggle_vertical_scrollbar(),
1781 Action::ToggleHorizontalScrollbar => self.toggle_horizontal_scrollbar(),
1782 Action::ToggleLineNumbers => self.toggle_line_numbers(),
1783 Action::ToggleLineNumbersCurrentBuffer => self.toggle_line_numbers_current_buffer(),
1784 Action::ToggleLineWrapCurrentBuffer => self.toggle_line_wrap_current_buffer(),
1785 Action::TriggerWaveAnimation => self.trigger_wave_animation(),
1786 Action::ToggleScrollSync => self.active_window_mut().toggle_scroll_sync(),
1787 Action::ToggleMouseCapture => self.toggle_mouse_capture(),
1788 Action::ToggleMouseHover => self.toggle_mouse_hover(),
1789 Action::ToggleDebugHighlights => self.active_window_mut().toggle_debug_highlights(),
1790 // Rulers
1791 Action::AddRuler => {
1792 self.start_prompt(t!("rulers.add_prompt").to_string(), PromptType::AddRuler);
1793 }
1794 Action::RemoveRuler => {
1795 self.start_remove_ruler_prompt();
1796 }
1797 // Buffer settings
1798 Action::SetTabSize => {
1799 let current = self
1800 .buffers()
1801 .get(&self.active_buffer())
1802 .map(|s| s.buffer_settings.tab_size.to_string())
1803 .unwrap_or_else(|| "4".to_string());
1804 self.start_prompt_with_initial_text(
1805 "Tab size: ".to_string(),
1806 PromptType::SetTabSize,
1807 current,
1808 );
1809 }
1810 Action::SetLineEnding => {
1811 self.start_set_line_ending_prompt();
1812 }
1813 Action::SetEncoding => {
1814 self.start_set_encoding_prompt();
1815 }
1816 Action::ReloadWithEncoding => {
1817 self.start_reload_with_encoding_prompt();
1818 }
1819 Action::SetLanguage => {
1820 self.start_set_language_prompt();
1821 }
1822 Action::ToggleIndentationStyle => {
1823 let __buffer_id = self.active_buffer();
1824 if let Some(state) = self
1825 .windows
1826 .get_mut(&self.active_window)
1827 .map(|w| &mut w.buffers)
1828 .expect("active window present")
1829 .get_mut(&__buffer_id)
1830 {
1831 state.buffer_settings.use_tabs = !state.buffer_settings.use_tabs;
1832 let status = if state.buffer_settings.use_tabs {
1833 "Indentation: Tabs"
1834 } else {
1835 "Indentation: Spaces"
1836 };
1837 self.set_status_message(status.to_string());
1838 }
1839 }
1840 Action::ToggleTabIndicators | Action::ToggleWhitespaceIndicators => {
1841 let __buffer_id = self.active_buffer();
1842 if let Some(state) = self
1843 .windows
1844 .get_mut(&self.active_window)
1845 .map(|w| &mut w.buffers)
1846 .expect("active window present")
1847 .get_mut(&__buffer_id)
1848 {
1849 state.buffer_settings.whitespace.toggle_all();
1850 let status = if state.buffer_settings.whitespace.any_visible() {
1851 t!("toggle.whitespace_indicators_shown")
1852 } else {
1853 t!("toggle.whitespace_indicators_hidden")
1854 };
1855 self.set_status_message(status.to_string());
1856 }
1857 }
1858 Action::ResetBufferSettings => self.reset_buffer_settings(),
1859 Action::FocusFileExplorer => self.focus_file_explorer(),
1860 Action::FocusEditor => self.active_window_mut().focus_editor(),
1861 Action::ToggleDockFocus => {
1862 // Bounce keyboard focus between the editor/explorer area and
1863 // the orchestrator dock. `dock` is `Some` whenever the dock is
1864 // mounted (focused or merely visible-but-blurred); the helpers
1865 // flip `focused` and fire the matching `focus`/`blur`
1866 // widget_event so the plugin's mirror stays in sync.
1867 match self.dock.as_ref().map(|d| d.focused) {
1868 Some(true) => self.blur_floating_panel(super::PanelSlot::Dock),
1869 Some(false) => self.refocus_floating_panel(super::PanelSlot::Dock),
1870 // Dock hidden: hand off to the orchestrator plugin's
1871 // show-dock command so one key both opens and focuses it.
1872 None => {
1873 return self.handle_action(Action::PluginAction(
1874 "orchestrator_dock_toggle".to_string(),
1875 ));
1876 }
1877 }
1878 }
1879 Action::FileExplorerUp => self.file_explorer_navigate_up(),
1880 Action::FileExplorerDown => self.file_explorer_navigate_down(),
1881 Action::FileExplorerPageUp => self.file_explorer_page_up(),
1882 Action::FileExplorerPageDown => self.file_explorer_page_down(),
1883 Action::FileExplorerExpand => self.file_explorer_toggle_expand(),
1884 Action::FileExplorerCollapse => self.file_explorer_collapse(),
1885 Action::FileExplorerOpen => self.file_explorer_open_file()?,
1886 Action::FileExplorerRefresh => self.file_explorer_refresh(),
1887 Action::FileExplorerNewFile => self.file_explorer_new_file(),
1888 Action::FileExplorerNewDirectory => self.file_explorer_new_directory(),
1889 Action::FileExplorerDelete => self.file_explorer_delete(),
1890 Action::FileExplorerRename => self.file_explorer_rename(),
1891 Action::FileExplorerToggleHidden => self.file_explorer_toggle_hidden(),
1892 Action::FileExplorerToggleGitignored => self.file_explorer_toggle_gitignored(),
1893 Action::FileExplorerSearchClear => {
1894 self.active_window_mut().file_explorer_search_clear()
1895 }
1896 Action::FileExplorerSearchBackspace => {
1897 self.active_window_mut().file_explorer_search_pop_char()
1898 }
1899 Action::FileExplorerCopy => self.active_window_mut().file_explorer_copy(),
1900 Action::FileExplorerCut => self.active_window_mut().file_explorer_cut(),
1901 Action::FileExplorerPaste => self.file_explorer_paste(),
1902 Action::FileExplorerDuplicate => self.file_explorer_duplicate(),
1903 Action::FileExplorerCopyFullPath => self.file_explorer_copy_path(false),
1904 Action::FileExplorerCopyRelativePath => self.file_explorer_copy_path(true),
1905 Action::FileExplorerExtendSelectionUp => {
1906 self.active_window_mut().file_explorer_extend_selection_up()
1907 }
1908 Action::FileExplorerExtendSelectionDown => self
1909 .active_window_mut()
1910 .file_explorer_extend_selection_down(),
1911 Action::FileExplorerToggleSelect => {
1912 self.active_window_mut().file_explorer_toggle_select()
1913 }
1914 Action::FileExplorerSelectAll => self.active_window_mut().file_explorer_select_all(),
1915 Action::RemoveSecondaryCursors => {
1916 // Convert action to events and apply them
1917 if let Some(events) = self
1918 .active_window_mut()
1919 .action_to_events(Action::RemoveSecondaryCursors)
1920 {
1921 // Wrap in batch for atomic undo
1922 let batch = Event::Batch {
1923 events: events.clone(),
1924 description: "Remove secondary cursors".to_string(),
1925 };
1926 self.active_event_log_mut().append(batch.clone());
1927 self.apply_event_to_active_buffer(&batch);
1928
1929 // Ensure the primary cursor is visible after removing secondary cursors
1930 let active_split = self
1931 .windows
1932 .get(&self.active_window)
1933 .and_then(|w| w.buffers.splits())
1934 .map(|(mgr, _)| mgr)
1935 .expect("active window must have a populated split layout")
1936 .active_split();
1937 let active_buffer = self.active_buffer();
1938 self.active_window_mut()
1939 .ensure_cursor_visible_for_split(active_buffer, active_split);
1940 }
1941 }
1942
1943 // Menu navigation actions
1944 Action::MenuActivate => {
1945 self.handle_menu_activate();
1946 }
1947 Action::MenuClose => {
1948 self.handle_menu_close();
1949 }
1950 Action::MenuLeft => {
1951 self.handle_menu_left();
1952 }
1953 Action::MenuRight => {
1954 self.handle_menu_right();
1955 }
1956 Action::MenuUp => {
1957 self.handle_menu_up();
1958 }
1959 Action::MenuDown => {
1960 self.handle_menu_down();
1961 }
1962 Action::MenuExecute => {
1963 if let Some(action) = self.handle_menu_execute() {
1964 return self.handle_action(action);
1965 }
1966 }
1967 Action::MenuOpen(menu_name) => {
1968 if self.config.editor.menu_bar_mnemonics {
1969 self.handle_menu_open(&menu_name);
1970 }
1971 }
1972
1973 Action::SwitchKeybindingMap(map_name) => {
1974 // Delegate to the shared helper so the menu path persists the
1975 // choice to the user config (issue #474), matching the
1976 // command-palette path. This handler previously duplicated the
1977 // switch logic but skipped persistence, so the keybinding style
1978 // reset to the default on the next launch.
1979 self.apply_keybinding_map(&map_name);
1980 }
1981
1982 Action::SmartHome => {
1983 // In composite (diff) views, use LineStart movement
1984 let buffer_id = self.active_buffer();
1985 if self.active_window().is_composite_buffer(buffer_id) {
1986 if let Some(_handled) =
1987 self.handle_composite_action(buffer_id, &Action::SmartHome)
1988 {
1989 return Ok(());
1990 }
1991 }
1992 self.smart_home();
1993 }
1994 Action::ToggleComment => {
1995 self.toggle_comment();
1996 }
1997 Action::ToggleFold => {
1998 self.active_window_mut().toggle_fold_at_cursor();
1999 }
2000 Action::GoToMatchingBracket => {
2001 self.goto_matching_bracket();
2002 }
2003 Action::JumpToNextError => {
2004 self.jump_to_next_error();
2005 }
2006 Action::JumpToPreviousError => {
2007 self.jump_to_previous_error();
2008 }
2009 Action::SetBookmark(key) => {
2010 self.active_window_mut().set_bookmark(key);
2011 }
2012 Action::JumpToBookmark(key) => {
2013 self.jump_to_bookmark(key);
2014 }
2015 Action::ClearBookmark(key) => {
2016 self.active_window_mut().clear_bookmark(key);
2017 }
2018 Action::ListBookmarks => {
2019 self.active_window_mut().list_bookmarks();
2020 }
2021 Action::ToggleSearchCaseSensitive if !self.active_prompt_has_search_options() => {}
2022 Action::ToggleSearchWholeWord if !self.active_prompt_has_search_options() => {}
2023 Action::ToggleSearchRegex if !self.active_prompt_has_search_options() => {}
2024 Action::ToggleSearchCaseSensitive => {
2025 self.active_window_mut().search_case_sensitive =
2026 !self.active_window().search_case_sensitive;
2027 let state = if self.active_window().search_case_sensitive {
2028 "enabled"
2029 } else {
2030 "disabled"
2031 };
2032 self.set_status_message(
2033 t!("search.case_sensitive_state", state = state).to_string(),
2034 );
2035 self.refresh_active_search();
2036 }
2037 Action::ToggleSearchWholeWord => {
2038 self.active_window_mut().search_whole_word =
2039 !self.active_window().search_whole_word;
2040 let state = if self.active_window().search_whole_word {
2041 "enabled"
2042 } else {
2043 "disabled"
2044 };
2045 self.set_status_message(t!("search.whole_word_state", state = state).to_string());
2046 self.refresh_active_search();
2047 }
2048 Action::ToggleSearchRegex => {
2049 self.active_window_mut().search_use_regex = !self.active_window().search_use_regex;
2050 let state = if self.active_window().search_use_regex {
2051 "enabled"
2052 } else {
2053 "disabled"
2054 };
2055 self.set_status_message(t!("search.regex_state", state = state).to_string());
2056 self.refresh_active_search();
2057 }
2058 Action::ToggleSearchConfirmEach => {
2059 self.active_window_mut().search_confirm_each =
2060 !self.active_window().search_confirm_each;
2061 let state = if self.active_window().search_confirm_each {
2062 "enabled"
2063 } else {
2064 "disabled"
2065 };
2066 self.set_status_message(t!("search.confirm_each_state", state = state).to_string());
2067 }
2068 Action::FileBrowserToggleHidden => {
2069 // Toggle hidden files in file browser (handled via file_open_toggle_hidden)
2070 self.file_open_toggle_hidden();
2071 }
2072 Action::StartMacroRecording => {
2073 // This is a no-op; use ToggleMacroRecording instead
2074 self.set_status_message(
2075 "Use Ctrl+Shift+R to start recording (will prompt for register)".to_string(),
2076 );
2077 }
2078 Action::StopMacroRecording => {
2079 self.stop_macro_recording();
2080 }
2081 Action::PlayMacro(key) => {
2082 self.play_macro(key);
2083 }
2084 Action::ToggleMacroRecording(key) => {
2085 self.toggle_macro_recording(key);
2086 }
2087 Action::ShowMacro(key) => {
2088 self.show_macro_in_buffer(key);
2089 }
2090 Action::ListMacros => {
2091 self.list_macros_in_buffer();
2092 }
2093 Action::PromptRecordMacro => {
2094 self.start_prompt("Record macro (0-9): ".to_string(), PromptType::RecordMacro);
2095 }
2096 Action::PromptPlayMacro => {
2097 self.start_prompt("Play macro (0-9): ".to_string(), PromptType::PlayMacro);
2098 }
2099 Action::PlayLastMacro => {
2100 if let Some(key) = self.active_window_mut().macros.last_register() {
2101 self.play_macro(key);
2102 } else {
2103 self.set_status_message(t!("status.no_macro_recorded").to_string());
2104 }
2105 }
2106 Action::PromptSaveMacroToInit => {
2107 self.start_prompt(
2108 "Save macro to init.ts (0-9): ".to_string(),
2109 PromptType::SaveMacroToInit,
2110 );
2111 }
2112 Action::PromptPromoteMacro => {
2113 self.start_prompt(
2114 "Promote macro to command (0-9): ".to_string(),
2115 PromptType::PromoteMacro,
2116 );
2117 }
2118 Action::PromptSetBookmark => {
2119 self.start_prompt("Set bookmark (0-9): ".to_string(), PromptType::SetBookmark);
2120 }
2121 Action::PromptJumpToBookmark => {
2122 self.start_prompt(
2123 "Jump to bookmark (0-9): ".to_string(),
2124 PromptType::JumpToBookmark,
2125 );
2126 }
2127 Action::CompositeNextHunk => {
2128 let buf = self.active_buffer();
2129 self.active_window_mut().composite_next_hunk_active(buf);
2130 }
2131 Action::CompositePrevHunk => {
2132 let buf = self.active_buffer();
2133 self.active_window_mut().composite_prev_hunk_active(buf);
2134 }
2135 Action::None => {}
2136 Action::DeleteBackward => {
2137 if self.active_window().is_editing_disabled() {
2138 self.set_status_message(t!("buffer.editing_disabled").to_string());
2139 return Ok(());
2140 }
2141 // Normal backspace handling
2142 if let Some(events) = self
2143 .active_window_mut()
2144 .action_to_events(Action::DeleteBackward)
2145 {
2146 if events.len() > 1 {
2147 // Multi-cursor: use optimized bulk edit (O(n) instead of O(n²))
2148 let description = "Delete backward".to_string();
2149 if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description)
2150 {
2151 self.active_event_log_mut().append(bulk_edit);
2152 }
2153 } else {
2154 for event in events {
2155 self.active_event_log_mut().append(event.clone());
2156 self.apply_event_to_active_buffer(&event);
2157 }
2158 }
2159 }
2160 }
2161 Action::PluginAction(action_name) => {
2162 tracing::debug!("handle_action: PluginAction('{}')", action_name);
2163 // Execute the plugin callback via TypeScript plugin thread
2164 // Use non-blocking version to avoid deadlock with async plugin ops
2165 #[cfg(feature = "plugins")]
2166 {
2167 let result = self
2168 .plugin_manager
2169 .read()
2170 .unwrap()
2171 .execute_action_async(&action_name);
2172 if let Some(result) = result {
2173 match result {
2174 Ok(receiver) => {
2175 // Store pending action for processing in main loop
2176 self.pending_plugin_actions
2177 .push((action_name.clone(), receiver));
2178 }
2179 Err(e) => {
2180 self.set_status_message(
2181 t!("view.plugin_error", error = e.to_string()).to_string(),
2182 );
2183 tracing::error!("Plugin action error: {}", e);
2184 }
2185 }
2186 } else {
2187 self.set_status_message(
2188 t!("status.plugin_manager_unavailable").to_string(),
2189 );
2190 }
2191 }
2192 #[cfg(not(feature = "plugins"))]
2193 {
2194 let _ = action_name;
2195 self.set_status_message(
2196 "Plugins not available (compiled without plugin support)".to_string(),
2197 );
2198 }
2199 }
2200 Action::LoadPluginFromBuffer => {
2201 #[cfg(feature = "plugins")]
2202 {
2203 let buffer_id = self.active_buffer();
2204 let state = self.active_state();
2205 let buffer = &state.buffer;
2206 let total = buffer.total_bytes();
2207 let content =
2208 String::from_utf8_lossy(&buffer.slice_bytes(0..total)).to_string();
2209
2210 // Determine if TypeScript from file extension, default to TS
2211 let is_ts = buffer
2212 .file_path()
2213 .and_then(|p| p.extension())
2214 .and_then(|e| e.to_str())
2215 .map(|e| e == "ts" || e == "tsx")
2216 .unwrap_or(true);
2217
2218 // Derive plugin name from buffer filename
2219 let name = buffer
2220 .file_path()
2221 .and_then(|p| p.file_name())
2222 .and_then(|s| s.to_str())
2223 .map(|s| s.to_string())
2224 .unwrap_or_else(|| "buffer-plugin".to_string());
2225
2226 let load_result = self
2227 .plugin_manager
2228 .read()
2229 .unwrap()
2230 .load_plugin_from_source(&content, &name, is_ts);
2231 match load_result {
2232 Ok(()) => {
2233 self.set_status_message(format!(
2234 "Plugin '{}' loaded from buffer",
2235 name
2236 ));
2237 }
2238 Err(e) => {
2239 self.set_status_message(format!("Failed to load plugin: {}", e));
2240 tracing::error!("LoadPluginFromBuffer error: {}", e);
2241 }
2242 }
2243
2244 // Set up plugin dev workspace for LSP support
2245 self.setup_plugin_dev_lsp(buffer_id, &content);
2246 }
2247 #[cfg(not(feature = "plugins"))]
2248 {
2249 self.set_status_message(
2250 "Plugins not available (compiled without plugin support)".to_string(),
2251 );
2252 }
2253 }
2254 Action::InitReload => {
2255 // Same code path as auto-load: read init.ts and push it
2256 // through the existing plugin pipeline. The runtime's
2257 // hot-reload semantics drop prior commands / handlers /
2258 // event subs / settings before the new source runs.
2259 self.load_init_script(true);
2260 // Re-fire plugins_loaded so handlers expecting a "fresh"
2261 // post-load environment (M2) see it.
2262 self.fire_plugins_loaded_hook();
2263 }
2264 Action::InitEdit => {
2265 // Ensure the file exists (create from template if absent),
2266 // then open it in the editor so users can edit + reload.
2267 let config_dir = self.dir_context.config_dir.clone();
2268 match crate::init_script::ensure_starter(&config_dir) {
2269 Ok(path) => {
2270 // Regenerate `types/plugins.d.ts` from the live plugin
2271 // set. It's written once at editor startup, but any
2272 // plugin loaded/reloaded/unloaded since then would
2273 // leave the aggregate stale (or missing, in builds
2274 // where the plugins feature was off at boot but the
2275 // user has since enabled a plugin). The user's
2276 // tsconfig.json lists this file in `files`, so a
2277 // stale copy is exactly when `getPluginApi("foo")`
2278 // loses its typed overload.
2279 let declarations =
2280 self.plugin_manager.read().unwrap().plugin_declarations();
2281 crate::init_script::write_plugin_declarations(&config_dir, &declarations);
2282 match self.open_file(&path) {
2283 Ok(_) => {
2284 self.set_status_message(format!("init.ts: {}", path.display()));
2285 }
2286 Err(e) => {
2287 self.set_status_message(format!("init.ts: open failed: {e}"));
2288 }
2289 }
2290 }
2291 Err(e) => {
2292 self.set_status_message(format!("init.ts: create failed: {e}"));
2293 }
2294 }
2295 }
2296 Action::InitCheck => {
2297 // Run the same parse check as `fresh --cmd init check` but
2298 // surface results in the status bar.
2299 let report = crate::init_script::check(&self.dir_context.config_dir);
2300 if report.ok && report.diagnostics.is_empty() {
2301 self.set_status_message("init.ts: ok".into());
2302 } else if !report.ok {
2303 let first = report
2304 .diagnostics
2305 .first()
2306 .map(|d| format!("{}:{}: {}", d.line, d.column, d.message))
2307 .unwrap_or_else(|| "unknown error".into());
2308 self.set_status_message(format!(
2309 "init.ts: {} error(s) — first: {first}",
2310 report.diagnostics.len()
2311 ));
2312 } else {
2313 self.set_status_message(format!(
2314 "init.ts: {} warning(s)",
2315 report.diagnostics.len()
2316 ));
2317 }
2318 }
2319 Action::OpenTerminal => {
2320 self.open_terminal();
2321 }
2322 Action::OpenTerminalRight => {
2323 self.open_terminal_split(crate::model::event::SplitDirection::Vertical);
2324 }
2325 Action::OpenTerminalBelow => {
2326 self.open_terminal_split(crate::model::event::SplitDirection::Horizontal);
2327 }
2328 Action::CloseTerminal => {
2329 self.close_terminal();
2330 }
2331 Action::FocusTerminal => {
2332 // If viewing a terminal buffer, switch to terminal mode
2333 if self
2334 .active_window()
2335 .is_terminal_buffer(self.active_buffer())
2336 {
2337 // Mode change to live: remember it for the next focus.
2338 let active = self.active_buffer();
2339 self.active_window_mut().set_terminal_interaction_mode(
2340 active,
2341 crate::app::window::TerminalInteractionMode::Live,
2342 );
2343 self.active_window_mut().terminal_mode = true;
2344 self.active_window_mut().key_context = KeyContext::Terminal;
2345 self.set_status_message(t!("status.terminal_mode_enabled").to_string());
2346 }
2347 }
2348 Action::TerminalEscape => {
2349 // Exit terminal mode back to editor
2350 if self.active_window().terminal_mode {
2351 // User dropped to read-only scrollback: remember that mode.
2352 let active = self.active_buffer();
2353 self.active_window_mut().set_terminal_interaction_mode(
2354 active,
2355 crate::app::window::TerminalInteractionMode::Scrollback,
2356 );
2357 self.active_window_mut().terminal_mode = false;
2358 self.active_window_mut().key_context = KeyContext::Normal;
2359 self.set_status_message(t!("status.terminal_mode_disabled").to_string());
2360 }
2361 }
2362 Action::ToggleKeyboardCapture => {
2363 // Toggle keyboard capture mode in terminal
2364 if self.active_window().terminal_mode {
2365 self.active_window_mut().keyboard_capture =
2366 !self.active_window_mut().keyboard_capture;
2367 if self.active_window_mut().keyboard_capture {
2368 self.set_status_message(
2369 "Keyboard capture ON - all keys go to terminal (F9 to toggle)"
2370 .to_string(),
2371 );
2372 } else {
2373 self.set_status_message(
2374 "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
2375 );
2376 }
2377 }
2378 }
2379 Action::TerminalPaste => {
2380 // Paste clipboard contents into terminal as a single batch
2381 if self.active_window().terminal_mode {
2382 if let Some(text) = self.clipboard.paste() {
2383 self.active_window_mut()
2384 .send_terminal_input(text.as_bytes());
2385 }
2386 }
2387 }
2388 Action::SendSelectionToTerminal => {
2389 self.send_selection_to_terminal();
2390 }
2391 Action::ShellCommand => {
2392 // Run shell command on buffer/selection, output to new buffer
2393 self.start_shell_command_prompt(false);
2394 }
2395 Action::ShellCommandReplace => {
2396 // Run shell command on buffer/selection, replace content
2397 self.start_shell_command_prompt(true);
2398 }
2399 Action::OpenSettings => {
2400 self.open_settings();
2401 }
2402 Action::CloseSettings => {
2403 // Check if there are unsaved changes
2404 let has_changes = self
2405 .settings_state
2406 .as_ref()
2407 .is_some_and(|s| s.has_changes());
2408 if has_changes {
2409 // Show confirmation dialog
2410 if let Some(ref mut state) = self.settings_state {
2411 state.show_confirm_dialog();
2412 }
2413 } else {
2414 self.close_settings(false);
2415 }
2416 }
2417 Action::SettingsSave => {
2418 self.save_settings();
2419 }
2420 Action::SettingsReset => {
2421 if let Some(ref mut state) = self.settings_state {
2422 state.reset_current_to_default();
2423 }
2424 }
2425 Action::SettingsInherit => {
2426 if let Some(ref mut state) = self.settings_state {
2427 state.set_current_to_null();
2428 }
2429 }
2430 Action::SettingsToggleFocus => {
2431 if let Some(ref mut state) = self.settings_state {
2432 state.toggle_focus();
2433 }
2434 }
2435 Action::SettingsActivate => {
2436 self.settings_activate_current();
2437 }
2438 Action::SettingsSearch => {
2439 if let Some(ref mut state) = self.settings_state {
2440 state.start_search();
2441 }
2442 }
2443 Action::SettingsHelp => {
2444 if let Some(ref mut state) = self.settings_state {
2445 state.toggle_help();
2446 }
2447 }
2448 Action::SettingsIncrement => {
2449 self.settings_increment_current();
2450 }
2451 Action::SettingsDecrement => {
2452 self.settings_decrement_current();
2453 }
2454 Action::CalibrateInput => {
2455 self.open_calibration_wizard();
2456 }
2457 Action::EventDebug => {
2458 self.active_window_mut().open_event_debug();
2459 }
2460 Action::SuspendProcess => {
2461 self.request_suspend();
2462 }
2463 Action::OpenKeybindingEditor => {
2464 self.open_keybinding_editor();
2465 }
2466 Action::PromptConfirm => {
2467 if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
2468 use super::prompt_actions::PromptResult;
2469 match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
2470 PromptResult::ExecuteAction(action) => {
2471 return self.handle_action(action);
2472 }
2473 PromptResult::EarlyReturn => {
2474 return Ok(());
2475 }
2476 PromptResult::Done => {}
2477 }
2478 }
2479 }
2480 Action::PromptConfirmWithText(ref text) => {
2481 // For macro playback: set the prompt text before confirming
2482 if let Some(ref mut prompt) = self.active_window_mut().prompt {
2483 prompt.set_input(text.clone());
2484 self.update_prompt_suggestions();
2485 }
2486 if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
2487 use super::prompt_actions::PromptResult;
2488 match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
2489 PromptResult::ExecuteAction(action) => {
2490 return self.handle_action(action);
2491 }
2492 PromptResult::EarlyReturn => {
2493 return Ok(());
2494 }
2495 PromptResult::Done => {}
2496 }
2497 }
2498 }
2499 Action::PopupConfirm => {
2500 use super::popup_actions::PopupConfirmResult;
2501 if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
2502 return Ok(());
2503 }
2504 }
2505 Action::PopupCancel => {
2506 self.handle_popup_cancel();
2507 }
2508 Action::PopupFocus => {
2509 self.handle_popup_focus();
2510 }
2511 Action::CompletionAccept => {
2512 use super::popup_actions::PopupConfirmResult;
2513 if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
2514 return Ok(());
2515 }
2516 }
2517 Action::CompletionDismiss => {
2518 self.handle_popup_cancel();
2519 }
2520 Action::InsertChar(c) => {
2521 if self.is_prompting() {
2522 return self.handle_insert_char_prompt(c);
2523 } else if self.active_window_mut().key_context == KeyContext::FileExplorer {
2524 self.active_window_mut().file_explorer_search_push_char(c);
2525 } else {
2526 self.handle_insert_char_editor(c)?;
2527 }
2528 }
2529 // Prompt clipboard actions
2530 Action::PromptCopy => {
2531 if let Some(prompt) = &self.active_window_mut().prompt {
2532 let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
2533 if !text.is_empty() {
2534 self.clipboard.copy(text);
2535 self.set_status_message(t!("clipboard.copied").to_string());
2536 }
2537 }
2538 }
2539 Action::PromptCut => {
2540 if let Some(prompt) = &self.active_window_mut().prompt {
2541 let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
2542 if !text.is_empty() {
2543 self.clipboard.copy(text);
2544 }
2545 }
2546 if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
2547 if prompt.has_selection() {
2548 prompt.delete_selection();
2549 } else {
2550 prompt.clear();
2551 }
2552 }
2553 self.set_status_message(t!("clipboard.cut").to_string());
2554 self.update_prompt_suggestions();
2555 }
2556 Action::PromptPaste => {
2557 if let Some(text) = self.clipboard.paste() {
2558 if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
2559 prompt.insert_str(&text);
2560 }
2561 self.update_prompt_suggestions();
2562 }
2563 }
2564 _ => {
2565 // TODO: Why do we have this catch-all? It seems like actions should either:
2566 // 1. Be handled explicitly above (like InsertChar, PopupConfirm, etc.)
2567 // 2. Or be converted to events consistently
2568 // This catch-all makes it unclear which actions go through event conversion
2569 // vs. direct handling. Consider making this explicit or removing the pattern.
2570 self.apply_action_as_events(action)?;
2571 }
2572 }
2573
2574 Ok(())
2575 }
2576
2577 /// Fire a `widget_event` at the plugin owning the dock, keyed to the
2578 /// `sessions` widget. Used for dock-only gestures (Enter-activate,
2579 /// the Alt+T/Alt+I/Alt+P filter toggles) that the dialog handles via
2580 /// an editor mode the dock can't use — see `dispatch_floating_widget_key`.
2581 fn fire_dock_widget_event(&self, panel_key: &crate::widgets::PanelKey, event_type: &str) {
2582 self.fire_widget_event(
2583 panel_key,
2584 "sessions".to_string(),
2585 event_type.to_string(),
2586 serde_json::json!({}),
2587 );
2588 }
2589
2590 /// Route a keystroke to the floating widget panel when one is
2591 /// mounted. Returns `true` if the key was consumed.
2592 ///
2593 /// Esc unmounts the panel and fires a `widget_event` `cancel`
2594 /// so the plugin can clean up its own state (clear mode, drop
2595 /// form state, etc.). Tab / S-Tab / Return / Space / Backspace /
2596 /// Delete / Home / End / Left / Right / Up / Down route through
2597 /// the same smart-key dispatch the bound mode handlers would
2598 /// use. Printable characters feed `textInputChar` to the
2599 /// currently focused TextInput.
2600 fn dispatch_floating_widget_key(
2601 &mut self,
2602 slot: super::PanelSlot,
2603 code: crossterm::event::KeyCode,
2604 modifiers: crossterm::event::KeyModifiers,
2605 ) -> bool {
2606 use crossterm::event::{KeyCode, KeyModifiers};
2607 let panel_key = match self.panel(slot) {
2608 Some(fwp) => fwp.panel_key.clone(),
2609 None => {
2610 tracing::debug!(
2611 target: "fresh::dock",
2612 ?slot,
2613 ?code,
2614 "dispatch_floating_widget_key: no panel mounted in slot — returning false"
2615 );
2616 return false;
2617 }
2618 };
2619 tracing::debug!(
2620 target: "fresh::dock",
2621 panel = %panel_key,
2622 ?slot,
2623 ?code,
2624 modifiers = ?modifiers,
2625 placement = ?self.panel(slot).map(|f| f.placement),
2626 focused = ?self.panel(slot).map(|f| f.focused),
2627 "dispatch_floating_widget_key: entry"
2628 );
2629 // The left dock handles Enter / Esc / Space / "/" here, at the
2630 // floating-panel layer, *independent of editor modes*. Editor
2631 // modes (`defineMode`) resolve against the active buffer's mode,
2632 // which the dock floats over — so a session whose buffer has a
2633 // local mode would shadow any global dock mode. Up/Down fall
2634 // through to the generic smart-key list nav below (which fires
2635 // the `select` event the plugin live-switches on).
2636 if matches!(
2637 self.panel(slot).map(|f| f.placement),
2638 Some(super::PanelPlacement::LeftDock { .. })
2639 ) {
2640 let on_filter = self
2641 .widget_registry
2642 .focus_key(&panel_key)
2643 .map(|k| k == "filter")
2644 .unwrap_or(false);
2645 // The project dropdown owns the keyboard while panel focus
2646 // sits on one of its `project-pick:` rows (the plugin moves
2647 // focus there when the menu opens). In that state ↑/↓ move
2648 // the dropdown cursor, Enter commits it, and Esc cancels —
2649 // all routed to the plugin as `dock_menu_*` events. Without
2650 // this, those keys fell through to the generic list nav
2651 // below and drove the session list *under* the open menu,
2652 // so the dropdown was visible but un-navigable by keyboard.
2653 let on_project_menu = self
2654 .widget_registry
2655 .focus_key(&panel_key)
2656 .map(|k| k.starts_with("project-pick:"))
2657 .unwrap_or(false);
2658 if on_project_menu {
2659 match code {
2660 KeyCode::Up => {
2661 self.fire_dock_widget_event(&panel_key, "dock_menu_prev");
2662 return true;
2663 }
2664 KeyCode::Down => {
2665 self.fire_dock_widget_event(&panel_key, "dock_menu_next");
2666 return true;
2667 }
2668 // Tab/Shift+Tab navigate the menu too, so they can't
2669 // tab focus *out* of the open dropdown into the dock
2670 // toolbar behind it.
2671 KeyCode::Tab if modifiers.contains(KeyModifiers::SHIFT) => {
2672 self.fire_dock_widget_event(&panel_key, "dock_menu_prev");
2673 return true;
2674 }
2675 KeyCode::BackTab => {
2676 self.fire_dock_widget_event(&panel_key, "dock_menu_prev");
2677 return true;
2678 }
2679 KeyCode::Tab => {
2680 self.fire_dock_widget_event(&panel_key, "dock_menu_next");
2681 return true;
2682 }
2683 KeyCode::Enter | KeyCode::Char(' ') => {
2684 self.fire_dock_widget_event(&panel_key, "dock_menu_accept");
2685 return true;
2686 }
2687 KeyCode::Esc => {
2688 self.fire_dock_widget_event(&panel_key, "dock_menu_cancel");
2689 return true;
2690 }
2691 _ => {}
2692 }
2693 }
2694 match code {
2695 KeyCode::Esc => {
2696 if on_filter {
2697 // Return from the filter to the session list.
2698 self.set_panel_focus_and_notify(&panel_key, "sessions".to_string());
2699 } else {
2700 // Leave the dock — focus the editor; dock stays visible.
2701 self.blur_floating_panel(slot);
2702 }
2703 return true;
2704 }
2705 KeyCode::Enter => {
2706 if on_filter {
2707 // Return from the filter to the session list.
2708 self.set_panel_focus_and_notify(&panel_key, "sessions".to_string());
2709 } else if self
2710 .widget_registry
2711 .focus_key(&panel_key)
2712 .map(|k| k == "sessions" || k.is_empty())
2713 .unwrap_or(true)
2714 {
2715 // Enter on the session list activates the highlighted
2716 // row. The plugin attaches a discovered (on-disk)
2717 // worktree as a new session, or — for a row already
2718 // backed by a live window — blurs to the editor (the
2719 // dock stays visible). Handled plugin-side so the
2720 // discovered-vs-live decision lives next to the
2721 // dialog's identical `activate` logic, not split across
2722 // the host (was: always blur, which silently dropped
2723 // the on-disk attach in the dock).
2724 self.fire_dock_widget_event(&panel_key, "dock_activate");
2725 } else {
2726 // A button or toggle is keyboard-focused (Tab-cycled
2727 // onto "+ New", "Manage", "view", the project menu, or
2728 // a checkbox). Run THAT control's action via the
2729 // generic smart-key dispatcher — which fires `activate`
2730 // for a Button and `toggle` for a Toggle — instead of
2731 // the list's dock_activate. Without this, Enter on a
2732 // focused button silently fell through to dock_activate
2733 // and merely re-focused the session list, so buttons
2734 // worked with the mouse but not the keyboard.
2735 self.handle_widget_command(
2736 &panel_key,
2737 fresh_core::api::WidgetAction::Key {
2738 key: "Enter".to_string(),
2739 },
2740 );
2741 }
2742 return true;
2743 }
2744 KeyCode::Char('/') if modifiers.is_empty() => {
2745 self.set_panel_focus_and_notify(&panel_key, "filter".to_string());
2746 return true;
2747 }
2748 KeyCode::Char('t' | 'T') if modifiers.contains(KeyModifiers::ALT) => {
2749 // Alt+T toggles "show all worktrees". In the dialog this is
2750 // an OPEN_MODE chord, but the dock has no editor mode (it
2751 // floats over the active buffer's mode), so route it as a
2752 // dock widget_event the plugin maps to the same toggle —
2753 // otherwise it falls through to the generic chord path and
2754 // merely blurs the dock.
2755 self.fire_dock_widget_event(&panel_key, "dock_toggle_worktrees");
2756 return true;
2757 }
2758 KeyCode::Char('i' | 'I') if modifiers.contains(KeyModifiers::ALT) => {
2759 // Alt+I toggles "show empty/1-file sessions" — same dock
2760 // routing rationale as Alt+T above.
2761 self.fire_dock_widget_event(&panel_key, "dock_toggle_trivial");
2762 return true;
2763 }
2764 KeyCode::Char('p' | 'P') if modifiers.contains(KeyModifiers::ALT) => {
2765 // Alt+P flips the project scope (current ↔ all) — same dock
2766 // routing rationale as Alt+T above.
2767 self.fire_dock_widget_event(&panel_key, "dock_toggle_scope");
2768 return true;
2769 }
2770 KeyCode::Char('n' | 'N') if modifiers.contains(KeyModifiers::ALT) => {
2771 // Alt+N opens the new-session form. Handled here (not
2772 // via an editor mode) because the dock floats over the
2773 // active buffer's mode; fire a `dock_new` widget_event
2774 // the plugin turns into "+ New" — and which now leaves
2775 // the dock mounted (the form is a separate slot).
2776 self.fire_widget_event(
2777 &panel_key,
2778 "sessions".to_string(),
2779 "dock_new".to_string(),
2780 serde_json::json!({}),
2781 );
2782 return true;
2783 }
2784 KeyCode::Char(' ') => {
2785 // Toggle the highlighted row's multi-select checkbox
2786 // (plugin owns the selection set).
2787 tracing::debug!(
2788 target: "fresh::dock",
2789 panel = %panel_key,
2790 focus_key = ?self.widget_registry.focus_key(&panel_key),
2791 "dispatch_floating_widget_key: Space on LeftDock — firing dock_space widget_event"
2792 );
2793 self.fire_widget_event(
2794 &panel_key,
2795 "sessions".to_string(),
2796 "dock_space".to_string(),
2797 serde_json::json!({}),
2798 );
2799 return true;
2800 }
2801 _ => {}
2802 }
2803 }
2804 let key_name: Option<&str> = match code {
2805 KeyCode::Esc => {
2806 // Mode-binding precedence: a plugin's `defineMode`
2807 // entry for Escape wins over the default
2808 // "Esc closes the modal" behaviour. Mirrors the
2809 // same has_explicit_binding check the named-key
2810 // and Ctrl/Alt-char branches below already run.
2811 // Lets a plugin claim Esc for a nested
2812 // dismiss-the-dropdown gesture before the
2813 // outermost cancel fires.
2814 let mode_has_binding = self
2815 .active_window()
2816 .editor_mode
2817 .as_ref()
2818 .map(|mode_name| {
2819 let key_event = crossterm::event::KeyEvent::new(code, modifiers);
2820 let mode_ctx =
2821 crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
2822 let keybindings = self.keybindings.read().unwrap();
2823 keybindings.has_explicit_binding(&key_event, &mode_ctx)
2824 })
2825 .unwrap_or(false);
2826 if mode_has_binding {
2827 return false;
2828 }
2829 let widget_key = self
2830 .widget_registry
2831 .get(&panel_key)
2832 .map(|p| p.focus_key.clone())
2833 .unwrap_or_default();
2834 self.fire_widget_event(
2835 &panel_key,
2836 widget_key,
2837 "cancel".to_string(),
2838 serde_json::json!({}),
2839 );
2840 *self.panel_opt_mut(slot) = None;
2841 let _ = self.widget_registry.unmount(&panel_key);
2842 return true;
2843 }
2844 KeyCode::Tab => Some(if modifiers.contains(KeyModifiers::SHIFT) {
2845 "Shift+Tab"
2846 } else {
2847 "Tab"
2848 }),
2849 KeyCode::BackTab => Some("Shift+Tab"),
2850 KeyCode::Enter => Some("Enter"),
2851 KeyCode::Backspace => Some("Backspace"),
2852 KeyCode::Delete => Some("Delete"),
2853 KeyCode::Home => Some("Home"),
2854 KeyCode::End => Some("End"),
2855 KeyCode::Left => Some("Left"),
2856 KeyCode::Right => Some("Right"),
2857 KeyCode::Up => Some("Up"),
2858 KeyCode::Down => Some("Down"),
2859 KeyCode::PageUp => Some("PageUp"),
2860 KeyCode::PageDown => Some("PageDown"),
2861 _ => None,
2862 };
2863 if let Some(name) = key_name {
2864 // Mode-binding precedence: if the active editor mode has a
2865 // plugin-defined binding for this key, let it win instead
2866 // of applying the floating panel's default smart-key
2867 // behaviour. This is what `defineMode` exists for — a
2868 // plugin saying "in MY mode, Enter does X" must be
2869 // authoritative, not silently overridden by the host's
2870 // generic "Enter = focus-advance" default. The orchestrator
2871 // New-Session form relies on this so Enter submits the
2872 // form regardless of which field is focused (matching the
2873 // dialog's `Enter: submit` hint).
2874 //
2875 // Important: only count bindings that are *explicitly* set
2876 // for the mode (user / default / plugin defaults). The
2877 // resolver's full `resolve()` falls back to Normal-context
2878 // bindings for any mode, which would falsely report Enter
2879 // as bound everywhere (Normal's Enter inserts a newline).
2880 // We check the three context-scoped maps directly so the
2881 // Normal-fallback path doesn't taint the precedence check.
2882 let mode_has_binding = self
2883 .active_window()
2884 .editor_mode
2885 .as_ref()
2886 .map(|mode_name| {
2887 let key_event = crossterm::event::KeyEvent::new(code, modifiers);
2888 let mode_ctx =
2889 crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
2890 let keybindings = self.keybindings.read().unwrap();
2891 keybindings.has_explicit_binding(&key_event, &mode_ctx)
2892 })
2893 .unwrap_or(false);
2894 if mode_has_binding {
2895 return false;
2896 }
2897 self.handle_widget_command(
2898 &panel_key,
2899 fresh_core::api::WidgetAction::Key {
2900 key: name.to_string(),
2901 },
2902 );
2903 return true;
2904 }
2905 if let KeyCode::Char(c) = code {
2906 // The active editor mode may have explicitly claimed this
2907 // char via `defineMode` — e.g. the Orchestrator picker
2908 // binds `Alt+N` (new session), `Alt+P` (scope), and `/`
2909 // (focus filter). Defer to that path so plugin-declared
2910 // modal shortcuts work. This now covers *plain* chars too
2911 // (not just Ctrl/Alt chords): a plugin that binds a bare
2912 // key like `/` gets it before the text-input fast path.
2913 // The trade-off is that a bound bare key can't also be
2914 // typed as text in that mode, which is what the plugin
2915 // asked for by binding it.
2916 {
2917 let mode_has_binding = self
2918 .active_window()
2919 .editor_mode
2920 .as_ref()
2921 .map(|mode_name| {
2922 let key_event = crossterm::event::KeyEvent::new(code, modifiers);
2923 let mode_ctx =
2924 crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
2925 let keybindings = self.keybindings.read().unwrap();
2926 keybindings.has_explicit_binding(&key_event, &mode_ctx)
2927 })
2928 .unwrap_or(false);
2929 if mode_has_binding {
2930 return false;
2931 }
2932 }
2933 // Ctrl/Alt-modified chords with no mode binding: a centered
2934 // modal swallows them (it must not leak keys to global
2935 // bindings like Ctrl-P). The non-modal dock does the
2936 // opposite — an unhandled shortcut returns focus to the
2937 // editor (blur) and falls through so the editor handles it
2938 // (e.g. Ctrl-P opens the command palette).
2939 if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
2940 if matches!(
2941 self.panel(slot).map(|f| f.placement),
2942 Some(super::PanelPlacement::LeftDock { .. })
2943 ) {
2944 self.blur_floating_panel(slot);
2945 return false;
2946 }
2947 return true;
2948 }
2949 let ch = if modifiers.contains(KeyModifiers::SHIFT) {
2950 c.to_uppercase().next().unwrap_or(c)
2951 } else {
2952 c
2953 };
2954 // Space is a special case on a focused Toggle / Button:
2955 // the convention is "Space activates the focused
2956 // control", not "insert a literal space". Route it
2957 // through the smart-key dispatcher (which fires
2958 // `widget_event { event_type: "toggle" }` on a Toggle,
2959 // `activate` on a Button) instead of the text-input
2960 // fast path. For a focused Text widget the smart-key
2961 // dispatcher still inserts " " as a char, so typing
2962 // spaces into Project Path / Agent Command keeps
2963 // working.
2964 if ch == ' ' {
2965 self.handle_widget_command(
2966 &panel_key,
2967 fresh_core::api::WidgetAction::Key {
2968 key: "Space".to_string(),
2969 },
2970 );
2971 return true;
2972 }
2973 self.handle_widget_command(
2974 &panel_key,
2975 fresh_core::api::WidgetAction::TextInputChar {
2976 text: ch.to_string(),
2977 },
2978 );
2979 return true;
2980 }
2981 // Any other keystroke that reaches here (function keys,
2982 // unhandled keycodes, etc.) is swallowed too — the modal
2983 // is the exclusive owner of the input channel until it
2984 // unmounts.
2985 true
2986 }
2987
2988 /// If the Quick Open prompt is currently open, cancel it and return `true`.
2989 /// All four Quick Open variants (CommandPalette, QuickOpen, QuickOpenBuffers,
2990 /// QuickOpenFiles) toggle off when invoked while the picker is already visible.
2991 fn close_quick_open_if_open(&mut self) -> bool {
2992 if let Some(prompt) = &self.active_window_mut().prompt {
2993 if prompt.prompt_type == PromptType::QuickOpen {
2994 self.cancel_prompt();
2995 return true;
2996 }
2997 }
2998 false
2999 }
3000
3001 /// Re-run the active search after a search-option flag is toggled.
3002 /// If a search prompt is open, updates incremental highlights from the
3003 /// prompt's current input. Otherwise re-executes the last completed search.
3004 fn refresh_active_search(&mut self) {
3005 if let Some(prompt) = &self.active_window_mut().prompt {
3006 if matches!(
3007 prompt.prompt_type,
3008 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch
3009 ) {
3010 let query = prompt.input.clone();
3011 self.update_search_highlights(&query);
3012 }
3013 } else if let Some(search_state) = &self.active_window().search_state {
3014 let query = search_state.query.clone();
3015 self.perform_search(&query);
3016 }
3017 }
3018
3019 /// Open a terminal in the utility dock, creating the dock split if none exists yet.
3020 fn handle_open_terminal_in_dock(&mut self) -> AnyhowResult<()> {
3021 use crate::model::event::SplitDirection;
3022 use crate::view::split::SplitRole;
3023
3024 if let Some(dock_leaf) = self
3025 .windows
3026 .get(&self.active_window)
3027 .and_then(|w| w.buffers.splits())
3028 .map(|(mgr, _)| mgr)
3029 .expect("active window must have a populated split layout")
3030 .find_leaf_by_role(SplitRole::UtilityDock)
3031 {
3032 // Existing dock — focus it and let the regular open_terminal path attach a new tab.
3033 self.windows
3034 .get_mut(&self.active_window)
3035 .and_then(|w| w.split_manager_mut())
3036 .expect("active window must have a populated split layout")
3037 .set_active_split(dock_leaf);
3038 self.open_terminal();
3039 return Ok(());
3040 }
3041
3042 // No dock yet. Spawn the PTY first so we have a real terminal buffer to seed the new
3043 // dock leaf with — otherwise the leaf would carry the user's previously-active buffer
3044 // as a placeholder and that buffer would linger as a phantom tab in the dock.
3045 let Some(terminal_id) = self.spawn_terminal_session() else {
3046 return Ok(());
3047 };
3048 let buffer_id = self.create_terminal_buffer_detached(terminal_id);
3049
3050 // Split at the root so the dock spans the full width below any pre-existing side-by-side panes.
3051 let new_leaf = self
3052 .windows
3053 .get_mut(&self.active_window)
3054 .and_then(|w| w.split_manager_mut())
3055 .expect("active window must have a populated split layout")
3056 .split_root_positioned(SplitDirection::Horizontal, buffer_id, 0.7, false)
3057 .map_err(|e| {
3058 self.set_status_message(format!("Failed to create dock for terminal: {}", e));
3059 });
3060 let Ok(new_leaf) = new_leaf else {
3061 return Ok(());
3062 };
3063
3064 let mut view_state = crate::view::split::SplitViewState::with_buffer(
3065 self.terminal_width,
3066 self.terminal_height,
3067 buffer_id,
3068 );
3069 // Terminal-dedicated splits never show line numbers or current-line highlight.
3070 // (Mirrors the plugin-terminal split setup in `create_plugin_terminal`.)
3071 view_state.apply_config_defaults(
3072 false,
3073 false,
3074 self.active_window().resolve_line_wrap_for_buffer(buffer_id),
3075 self.config.editor.wrap_indent,
3076 self.active_window()
3077 .resolve_wrap_column_for_buffer(buffer_id),
3078 self.config.editor.rulers.clone(),
3079 0,
3080 );
3081 // Terminals don't wrap — keep escape sequences intact.
3082 view_state.viewport.line_wrap_enabled = false;
3083
3084 self.windows
3085 .get_mut(&self.active_window)
3086 .and_then(|w| w.split_view_states_mut())
3087 .expect("active window must have a populated split layout")
3088 .insert(new_leaf, view_state);
3089 self.windows
3090 .get_mut(&self.active_window)
3091 .and_then(|w| w.split_manager_mut())
3092 .expect("active window must have a populated split layout")
3093 .set_leaf_role(new_leaf, Some(SplitRole::UtilityDock));
3094 self.windows
3095 .get_mut(&self.active_window)
3096 .and_then(|w| w.split_manager_mut())
3097 .expect("active window must have a populated split layout")
3098 .set_active_split(new_leaf);
3099
3100 // Mirror open_terminal's post-attach bookkeeping. The buffer was
3101 // created via `create_terminal_buffer_detached`, so its remembered
3102 // mode is already Live.
3103 self.active_window_mut().terminal_mode = true;
3104 self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Terminal;
3105 self.active_window_mut().resize_visible_terminals();
3106
3107 let exit_key = self
3108 .keybindings
3109 .read()
3110 .unwrap()
3111 .find_keybinding_for_action(
3112 "terminal_escape",
3113 crate::input::keybindings::KeyContext::Terminal,
3114 )
3115 .unwrap_or_else(|| "Ctrl+Space".to_string());
3116 self.set_status_message(
3117 rust_i18n::t!("terminal.opened", id = terminal_id.0, exit_key = exit_key).to_string(),
3118 );
3119 tracing::info!(
3120 "Opened terminal {:?} into new dock leaf {:?} (buffer {:?})",
3121 terminal_id,
3122 new_leaf,
3123 buffer_id
3124 );
3125 Ok(())
3126 }
3127}