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