sql_cli/ui/key_handling/
mapper.rs

1// Maps keyboard input to actions
2// This will gradually replace direct key handling in TUI
3
4use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5use std::collections::HashMap;
6
7use crate::buffer::AppMode;
8use crate::ui::input::actions::{Action, ActionContext, CursorPosition, NavigateAction};
9
10/// Maps keyboard input to actions based on context
11pub struct KeyMapper {
12    /// Static mappings that don't depend on mode
13    global_mappings: HashMap<(KeyCode, KeyModifiers), Action>,
14
15    /// Mode-specific mappings
16    mode_mappings: HashMap<AppMode, HashMap<(KeyCode, KeyModifiers), Action>>,
17
18    /// Vim-style count buffer for motions
19    count_buffer: String,
20
21    /// Buffer for multi-character vim commands (e.g., 'wa', 'oa')
22    vim_command_buffer: String,
23}
24
25impl KeyMapper {
26    #[must_use]
27    pub fn new() -> Self {
28        let mut mapper = Self {
29            global_mappings: HashMap::new(),
30            mode_mappings: HashMap::new(),
31            count_buffer: String::new(),
32            vim_command_buffer: String::new(),
33        };
34
35        mapper.init_global_mappings();
36        mapper.init_mode_mappings();
37        mapper
38    }
39
40    /// Initialize mappings that work regardless of mode
41    fn init_global_mappings(&mut self) {
42        use KeyCode::{Char, F};
43        use KeyModifiers as Mod;
44
45        // Function keys that work in any mode
46        self.global_mappings
47            .insert((F(1), Mod::NONE), Action::ShowHelp);
48        self.global_mappings
49            .insert((F(3), Mod::NONE), Action::ShowPrettyQuery);
50        self.global_mappings
51            .insert((F(5), Mod::NONE), Action::ShowDebugInfo);
52        self.global_mappings
53            .insert((F(6), Mod::NONE), Action::ToggleRowNumbers);
54        self.global_mappings
55            .insert((F(7), Mod::NONE), Action::ToggleCompactMode);
56        self.global_mappings
57            .insert((F(8), Mod::NONE), Action::ToggleCaseInsensitive);
58        self.global_mappings
59            .insert((F(9), Mod::NONE), Action::KillLine);
60        self.global_mappings
61            .insert((F(10), Mod::NONE), Action::KillLineBackward);
62        self.global_mappings
63            .insert((F(12), Mod::NONE), Action::ToggleKeyIndicator);
64
65        // Force quit
66        self.global_mappings
67            .insert((Char('c'), Mod::CONTROL), Action::ForceQuit);
68        self.global_mappings
69            .insert((Char('C'), Mod::CONTROL), Action::ForceQuit);
70    }
71
72    /// Initialize mode-specific mappings
73    fn init_mode_mappings(&mut self) {
74        self.init_results_mappings();
75        self.init_command_mappings();
76        // Add other modes as we migrate them
77    }
78
79    /// Initialize Results mode mappings
80    fn init_results_mappings(&mut self) {
81        use crate::buffer::AppMode;
82        use KeyCode::{Char, Down, End, Esc, Home, Left, PageDown, PageUp, Right, Up, F};
83        use KeyModifiers as Mod;
84
85        let mut mappings = HashMap::new();
86
87        // Basic navigation (will be extracted in Phase 2)
88        mappings.insert((Up, Mod::NONE), Action::Navigate(NavigateAction::Up(1)));
89        mappings.insert((Down, Mod::NONE), Action::Navigate(NavigateAction::Down(1)));
90        mappings.insert((Left, Mod::NONE), Action::Navigate(NavigateAction::Left(1)));
91        mappings.insert(
92            (Right, Mod::NONE),
93            Action::Navigate(NavigateAction::Right(1)),
94        );
95
96        mappings.insert(
97            (PageUp, Mod::NONE),
98            Action::Navigate(NavigateAction::PageUp),
99        );
100        mappings.insert(
101            (PageDown, Mod::NONE),
102            Action::Navigate(NavigateAction::PageDown),
103        );
104
105        // Ctrl+F/B for page navigation (vim style)
106        mappings.insert(
107            (Char('f'), Mod::CONTROL),
108            Action::Navigate(NavigateAction::PageDown),
109        );
110        mappings.insert(
111            (Char('b'), Mod::CONTROL),
112            Action::Navigate(NavigateAction::PageUp),
113        );
114
115        mappings.insert((Home, Mod::NONE), Action::Navigate(NavigateAction::Home));
116        mappings.insert((End, Mod::NONE), Action::Navigate(NavigateAction::End));
117
118        // Vim navigation
119        mappings.insert(
120            (Char('h'), Mod::NONE),
121            Action::Navigate(NavigateAction::Left(1)),
122        );
123        mappings.insert(
124            (Char('j'), Mod::NONE),
125            Action::Navigate(NavigateAction::Down(1)),
126        );
127        mappings.insert(
128            (Char('k'), Mod::NONE),
129            Action::Navigate(NavigateAction::Up(1)),
130        );
131        mappings.insert(
132            (Char('l'), Mod::NONE),
133            Action::Navigate(NavigateAction::Right(1)),
134        );
135
136        // Arrow keys (same as vim navigation - no mode switching)
137        mappings.insert((Left, Mod::NONE), Action::Navigate(NavigateAction::Left(1)));
138        mappings.insert(
139            (Right, Mod::NONE),
140            Action::Navigate(NavigateAction::Right(1)),
141        );
142        mappings.insert((Down, Mod::NONE), Action::Navigate(NavigateAction::Down(1)));
143        mappings.insert((Up, Mod::NONE), Action::Navigate(NavigateAction::Up(1))); // Up navigates up, bounded at row 0
144
145        // Page navigation
146        mappings.insert(
147            (PageUp, Mod::NONE),
148            Action::Navigate(NavigateAction::PageUp),
149        );
150        mappings.insert(
151            (PageDown, Mod::NONE),
152            Action::Navigate(NavigateAction::PageDown),
153        );
154
155        // Home/End navigation (using traditional vim gg/G pattern)
156        // Note: Single 'g' is reserved for vim command sequences like 'ga'
157        // Use 'gg' for go to top (handled in vim command sequences)
158        mappings.insert(
159            (Char('G'), Mod::SHIFT),
160            Action::Navigate(NavigateAction::End),
161        );
162
163        // First/Last column navigation
164        mappings.insert(
165            (Char('0'), Mod::NONE),
166            Action::Navigate(NavigateAction::FirstColumn),
167        );
168        mappings.insert(
169            (Char('^'), Mod::NONE),
170            Action::Navigate(NavigateAction::FirstColumn),
171        );
172        mappings.insert(
173            (Char('$'), Mod::NONE),
174            Action::Navigate(NavigateAction::LastColumn),
175        );
176
177        // Viewport navigation (H/M/L like vim)
178        mappings.insert((Char('H'), Mod::SHIFT), Action::NavigateToViewportTop);
179        mappings.insert((Char('M'), Mod::SHIFT), Action::NavigateToViewportMiddle);
180        mappings.insert((Char('L'), Mod::SHIFT), Action::NavigateToViewportBottom);
181
182        // Mode switching
183        mappings.insert((Esc, Mod::NONE), Action::ExitCurrentMode);
184        mappings.insert((Char('q'), Mod::NONE), Action::Quit);
185        mappings.insert((Char('c'), Mod::CONTROL), Action::Quit); // Ctrl+C to quit
186
187        // F2 to switch to Command mode
188        mappings.insert((F(2), Mod::NONE), Action::SwitchMode(AppMode::Command));
189
190        // Vim-style 'i' for insert/input mode (switch to Command at current position)
191        mappings.insert(
192            (Char('i'), Mod::NONE),
193            Action::SwitchModeWithCursor(AppMode::Command, CursorPosition::Current),
194        );
195
196        // Vim-style 'a' for append mode (switch to Command at end)
197        mappings.insert(
198            (Char('a'), Mod::NONE),
199            Action::SwitchModeWithCursor(AppMode::Command, CursorPosition::End),
200        );
201
202        // Column operations
203        mappings.insert((Char('p'), Mod::NONE), Action::ToggleColumnPin);
204        mappings.insert((Char('-'), Mod::NONE), Action::HideColumn); // '-' to hide column
205        mappings.insert(
206            (Char('H'), Mod::CONTROL | Mod::SHIFT),
207            Action::UnhideAllColumns,
208        );
209        mappings.insert((Char('+'), Mod::NONE), Action::UnhideAllColumns); // '+' to unhide all
210        mappings.insert((Char('='), Mod::NONE), Action::UnhideAllColumns); // '=' to unhide all (easier than shift+= for +)
211                                                                           // Handle both lowercase and uppercase 'e' for hide empty columns
212        mappings.insert((Char('e'), Mod::NONE), Action::HideEmptyColumns);
213        mappings.insert((Char('E'), Mod::SHIFT), Action::HideEmptyColumns);
214        mappings.insert((Left, Mod::SHIFT), Action::MoveColumnLeft);
215        mappings.insert((Right, Mod::SHIFT), Action::MoveColumnRight);
216        // Also support < and > characters for column movement (more intuitive)
217        mappings.insert((Char('<'), Mod::NONE), Action::MoveColumnLeft);
218        mappings.insert((Char('>'), Mod::NONE), Action::MoveColumnRight);
219        // Search and filter operations
220        mappings.insert((Char('/'), Mod::NONE), Action::StartSearch);
221        mappings.insert((Char('\\'), Mod::NONE), Action::StartColumnSearch);
222        mappings.insert((Char('f'), Mod::NONE), Action::StartFilter);
223        mappings.insert((Char('F'), Mod::SHIFT), Action::StartFuzzyFilter);
224
225        // Sorting
226        mappings.insert((Char('s'), Mod::NONE), Action::Sort(None));
227
228        // View toggles
229        mappings.insert((Char('N'), Mod::NONE), Action::ToggleRowNumbers);
230        mappings.insert((Char('C'), Mod::NONE), Action::ToggleCompactMode);
231
232        // Export operations
233        mappings.insert((Char('x'), Mod::CONTROL), Action::ExportToCsv);
234        mappings.insert((Char('j'), Mod::CONTROL), Action::ExportToJson);
235
236        // Clear filter (when filter is active)
237        mappings.insert((Char('C'), Mod::SHIFT), Action::ClearFilter);
238
239        // Jump to row
240        mappings.insert((Char(':'), Mod::NONE), Action::StartJumpToRow);
241
242        // Search navigation
243        mappings.insert((Char('n'), Mod::NONE), Action::NextSearchMatch);
244        mappings.insert((Char('N'), Mod::SHIFT), Action::PreviousSearchMatch);
245
246        // Selection mode toggle (v key like vim visual mode)
247        mappings.insert((Char('v'), Mod::NONE), Action::ToggleSelectionMode);
248
249        // Column statistics
250        mappings.insert((Char('S'), Mod::SHIFT), Action::ShowColumnStatistics);
251
252        // Column packing mode
253        mappings.insert((Char('s'), Mod::ALT), Action::CycleColumnPacking);
254
255        // Viewport/cursor lock operations
256        mappings.insert((Char(' '), Mod::NONE), Action::ToggleViewportLock);
257        mappings.insert((Char('x'), Mod::NONE), Action::ToggleCursorLock);
258        mappings.insert((Char('X'), Mod::SHIFT), Action::ToggleCursorLock);
259        mappings.insert((Char(' '), Mod::CONTROL), Action::ToggleViewportLock);
260
261        // Additional help key
262        mappings.insert((Char('?'), Mod::NONE), Action::ShowHelp); // ? also shows help
263                                                                   // F-key actions are now handled globally
264
265        // Clear pins
266        mappings.insert((Char('P'), Mod::SHIFT), Action::ClearAllPins);
267
268        // History search
269        mappings.insert((Char('r'), Mod::CONTROL), Action::StartHistorySearch);
270
271        self.mode_mappings.insert(AppMode::Results, mappings);
272    }
273
274    /// Initialize Command mode mappings
275    fn init_command_mappings(&mut self) {
276        use crate::buffer::AppMode;
277        use KeyCode::{Backspace, Char, Delete, Down, End, Enter, Home, Left, Right, Up, F};
278        use KeyModifiers as Mod;
279
280        let mut mappings = HashMap::new();
281
282        // Execute query
283        mappings.insert((Enter, Mod::NONE), Action::ExecuteQuery);
284
285        // F2 to switch back to Results mode (if results exist)
286        mappings.insert((F(2), Mod::NONE), Action::SwitchMode(AppMode::Results));
287
288        // Clear line
289        mappings.insert((Char('u'), Mod::CONTROL), Action::ClearLine);
290
291        // Undo/Redo
292        mappings.insert((Char('z'), Mod::CONTROL), Action::Undo);
293        mappings.insert((Char('y'), Mod::CONTROL), Action::Redo);
294
295        // Cursor movement
296        mappings.insert((Left, Mod::NONE), Action::MoveCursorLeft);
297        mappings.insert((Right, Mod::NONE), Action::MoveCursorRight);
298        mappings.insert((Down, Mod::NONE), Action::SwitchMode(AppMode::Results)); // Down enters Results mode
299        mappings.insert((Home, Mod::NONE), Action::MoveCursorHome);
300        mappings.insert((End, Mod::NONE), Action::MoveCursorEnd);
301        mappings.insert((Char('a'), Mod::CONTROL), Action::MoveCursorHome);
302        mappings.insert((Char('e'), Mod::CONTROL), Action::MoveCursorEnd);
303        mappings.insert((Left, Mod::CONTROL), Action::MoveCursorWordLeft);
304        mappings.insert((Right, Mod::CONTROL), Action::MoveCursorWordRight);
305        mappings.insert((Char('b'), Mod::ALT), Action::MoveCursorWordLeft);
306        mappings.insert((Char('f'), Mod::ALT), Action::MoveCursorWordRight);
307
308        // Text editing
309        mappings.insert((Backspace, Mod::NONE), Action::Backspace);
310        mappings.insert((Delete, Mod::NONE), Action::Delete);
311        mappings.insert((Char('w'), Mod::CONTROL), Action::DeleteWordBackward);
312        mappings.insert((Char('d'), Mod::ALT), Action::DeleteWordForward);
313        mappings.insert((Char('k'), Mod::CONTROL), Action::KillLine);
314        // F9 and F10 are now handled globally
315
316        // Clipboard operations
317        mappings.insert((Char('v'), Mod::CONTROL), Action::Paste);
318
319        // History navigation
320        mappings.insert((Char('p'), Mod::CONTROL), Action::PreviousHistoryCommand);
321        mappings.insert((Char('n'), Mod::CONTROL), Action::NextHistoryCommand);
322        mappings.insert((Up, Mod::ALT), Action::PreviousHistoryCommand);
323        mappings.insert((Down, Mod::ALT), Action::NextHistoryCommand);
324
325        // SQL expansion operations
326        mappings.insert((Char('*'), Mod::CONTROL), Action::ExpandAsterisk);
327        mappings.insert((Char('*'), Mod::ALT), Action::ExpandAsteriskVisible);
328
329        // F-key actions are now handled globally
330
331        self.mode_mappings.insert(AppMode::Command, mappings);
332    }
333
334    /// Map a key event to an action based on current context
335    pub fn map_key(&mut self, key: KeyEvent, context: &ActionContext) -> Option<Action> {
336        // Handle vim-style counts and commands in Results mode
337        if context.mode == AppMode::Results {
338            if let KeyCode::Char(c) = key.code {
339                if key.modifiers.is_empty() {
340                    // Check if we're building a vim command (only for 'gg' now, others moved to chords)
341                    if !self.vim_command_buffer.is_empty() {
342                        // We have a pending command, check for valid combinations
343                        let command = format!("{}{}", self.vim_command_buffer, c);
344                        let action = if command.as_str() == "gg" {
345                            // Go to top (vim-style)
346                            self.vim_command_buffer.clear();
347                            Some(Action::Navigate(NavigateAction::Home))
348                        } else {
349                            // Invalid command, clear buffer
350                            self.vim_command_buffer.clear();
351                            None
352                        };
353
354                        if action.is_some() {
355                            return action;
356                        }
357                    }
358
359                    // Check for digits (vim counts)
360                    if c.is_ascii_digit() {
361                        self.count_buffer.push(c);
362                        return None; // Collecting count, no action yet
363                    }
364
365                    // Check if this starts a vim command sequence, but only if no standalone mapping exists
366                    // Only 'g' is used for vim commands now (gg = go to top)
367                    // SQL clause navigation moved to chord handler (cw, cs, etc.)
368                    if c == 'g' {
369                        let key_combo = (key.code, key.modifiers);
370                        if let Some(mode_mappings) = self.mode_mappings.get(&context.mode) {
371                            if mode_mappings.contains_key(&key_combo) {
372                                // This key has a standalone mapping, let it fall through to normal mapping
373                                // Don't treat it as a vim command starter
374                                tracing::debug!(
375                                    "Key '{}' has standalone mapping, not treating as vim command",
376                                    c
377                                );
378                            } else {
379                                // No standalone mapping, treat as vim command starter
380                                self.vim_command_buffer.push(c);
381                                tracing::debug!("Starting vim command buffer with '{}'", c);
382                                return None; // Collecting command, no action yet
383                            }
384                        }
385                    }
386                }
387            }
388        }
389
390        // Check for action with count
391        let action = self.map_key_internal(key, context);
392
393        // Apply count if we have one
394        if !self.count_buffer.is_empty() {
395            if let Some(mut action) = action {
396                if let Ok(count) = self.count_buffer.parse::<usize>() {
397                    action = self.apply_count_to_action(action, count);
398                }
399                self.count_buffer.clear();
400                return Some(action);
401            }
402            // If no action, clear count buffer
403            self.count_buffer.clear();
404        }
405
406        action
407    }
408
409    /// Internal key mapping without count handling
410    fn map_key_internal(&self, key: KeyEvent, context: &ActionContext) -> Option<Action> {
411        let key_combo = (key.code, key.modifiers);
412
413        // Check global mappings first
414        if let Some(action) = self.global_mappings.get(&key_combo) {
415            return Some(action.clone());
416        }
417
418        // Check mode-specific mappings
419        if let Some(mode_mappings) = self.mode_mappings.get(&context.mode) {
420            if let Some(action) = mode_mappings.get(&key_combo) {
421                return Some(action.clone());
422            }
423        }
424
425        // Handle regular character input in Command mode
426        if context.mode == AppMode::Command {
427            if let KeyCode::Char(c) = key.code {
428                if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
429                    // Regular character input
430                    return Some(Action::InsertChar(c));
431                }
432            }
433        }
434
435        // No mapping found
436        None
437    }
438
439    /// Apply a count to an action (for vim-style motions)
440    fn apply_count_to_action(&self, action: Action, count: usize) -> Action {
441        match action {
442            Action::Navigate(NavigateAction::Up(_)) => Action::Navigate(NavigateAction::Up(count)),
443            Action::Navigate(NavigateAction::Down(_)) => {
444                Action::Navigate(NavigateAction::Down(count))
445            }
446            Action::Navigate(NavigateAction::Left(_)) => {
447                Action::Navigate(NavigateAction::Left(count))
448            }
449            Action::Navigate(NavigateAction::Right(_)) => {
450                Action::Navigate(NavigateAction::Right(count))
451            }
452            // Other actions don't support counts yet
453            _ => action,
454        }
455    }
456
457    /// Clear any pending state (like count buffer and vim command buffer)
458    pub fn clear_pending(&mut self) {
459        self.count_buffer.clear();
460        self.vim_command_buffer.clear();
461    }
462
463    /// Check if we're collecting a count
464    #[must_use]
465    pub fn is_collecting_count(&self) -> bool {
466        !self.count_buffer.is_empty()
467    }
468
469    /// Get the current count buffer for display
470    #[must_use]
471    pub fn get_count_buffer(&self) -> &str {
472        &self.count_buffer
473    }
474}
475
476impl Default for KeyMapper {
477    fn default() -> Self {
478        Self::new()
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::app_state_container::SelectionMode;
486
487    #[test]
488    fn test_basic_navigation_mapping() {
489        let mut mapper = KeyMapper::new();
490        let context = ActionContext {
491            mode: AppMode::Results,
492            selection_mode: SelectionMode::Row,
493            has_results: true,
494            has_filter: false,
495            has_search: false,
496            row_count: 100,
497            column_count: 10,
498            current_row: 0,
499            current_column: 0,
500        };
501
502        // Test arrow down
503        let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
504        let action = mapper.map_key(key, &context);
505        assert_eq!(action, Some(Action::Navigate(NavigateAction::Down(1))));
506
507        // Test vim j
508        let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
509        let action = mapper.map_key(key, &context);
510        assert_eq!(action, Some(Action::Navigate(NavigateAction::Down(1))));
511    }
512
513    #[test]
514    fn test_vim_count_motion() {
515        let mut mapper = KeyMapper::new();
516        let context = ActionContext {
517            mode: AppMode::Results,
518            selection_mode: SelectionMode::Row,
519            has_results: true,
520            has_filter: false,
521            has_search: false,
522            row_count: 100,
523            column_count: 10,
524            current_row: 0,
525            current_column: 0,
526        };
527
528        // Type "5"
529        let key = KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE);
530        let action = mapper.map_key(key, &context);
531        assert_eq!(action, None); // No action yet, collecting count
532        assert_eq!(mapper.get_count_buffer(), "5");
533
534        // Type "j"
535        let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
536        let action = mapper.map_key(key, &context);
537        assert_eq!(action, Some(Action::Navigate(NavigateAction::Down(5))));
538        assert_eq!(mapper.get_count_buffer(), ""); // Buffer cleared
539    }
540
541    #[test]
542    fn test_global_mapping_override() {
543        let mut mapper = KeyMapper::new();
544        let context = ActionContext {
545            mode: AppMode::Results,
546            selection_mode: SelectionMode::Row,
547            has_results: true,
548            has_filter: false,
549            has_search: false,
550            row_count: 100,
551            column_count: 10,
552            current_row: 0,
553            current_column: 0,
554        };
555
556        // F1 should work in any mode
557        let key = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
558        let action = mapper.map_key(key, &context);
559        assert_eq!(action, Some(Action::ShowHelp));
560    }
561
562    #[test]
563    fn test_command_mode_editing_actions() {
564        let mut mapper = KeyMapper::new();
565        let context = ActionContext {
566            mode: AppMode::Command,
567            selection_mode: SelectionMode::Row,
568            has_results: false,
569            has_filter: false,
570            has_search: false,
571            row_count: 0,
572            column_count: 0,
573            current_row: 0,
574            current_column: 0,
575        };
576
577        // Test character input
578        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
579        let action = mapper.map_key(key, &context);
580        assert_eq!(action, Some(Action::InsertChar('a')));
581
582        // Test uppercase character
583        let key = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
584        let action = mapper.map_key(key, &context);
585        assert_eq!(action, Some(Action::InsertChar('A')));
586
587        // Test backspace
588        let key = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
589        let action = mapper.map_key(key, &context);
590        assert_eq!(action, Some(Action::Backspace));
591
592        // Test delete
593        let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE);
594        let action = mapper.map_key(key, &context);
595        assert_eq!(action, Some(Action::Delete));
596
597        // Test cursor movement - left
598        let key = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
599        let action = mapper.map_key(key, &context);
600        assert_eq!(action, Some(Action::MoveCursorLeft));
601
602        // Test cursor movement - right
603        let key = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
604        let action = mapper.map_key(key, &context);
605        assert_eq!(action, Some(Action::MoveCursorRight));
606
607        // Test Ctrl+A (home)
608        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
609        let action = mapper.map_key(key, &context);
610        assert_eq!(action, Some(Action::MoveCursorHome));
611
612        // Test Ctrl+E (end)
613        let key = KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL);
614        let action = mapper.map_key(key, &context);
615        assert_eq!(action, Some(Action::MoveCursorEnd));
616
617        // Test Ctrl+U (clear line)
618        let key = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
619        let action = mapper.map_key(key, &context);
620        assert_eq!(action, Some(Action::ClearLine));
621
622        // Test Ctrl+W (delete word backward)
623        let key = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL);
624        let action = mapper.map_key(key, &context);
625        assert_eq!(action, Some(Action::DeleteWordBackward));
626
627        // Test Ctrl+Z (undo)
628        let key = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL);
629        let action = mapper.map_key(key, &context);
630        assert_eq!(action, Some(Action::Undo));
631
632        // Test Enter (execute query)
633        let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
634        let action = mapper.map_key(key, &context);
635        assert_eq!(action, Some(Action::ExecuteQuery));
636    }
637
638    #[test]
639    fn test_vim_style_append_modes() {
640        let mut mapper = KeyMapper::new();
641        let context = ActionContext {
642            mode: AppMode::Results,
643            selection_mode: SelectionMode::Row,
644            has_results: true,
645            has_filter: false,
646            has_search: false,
647            row_count: 100,
648            column_count: 10,
649            current_row: 0,
650            current_column: 0,
651        };
652
653        // Test 'i' for insert at current
654        let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
655        let action = mapper.map_key(key, &context);
656        assert_eq!(
657            action,
658            Some(Action::SwitchModeWithCursor(
659                AppMode::Command,
660                CursorPosition::Current
661            ))
662        );
663
664        // Test 'a' for append at end
665        let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
666        let action = mapper.map_key(key, &context);
667        assert_eq!(
668            action,
669            Some(Action::SwitchModeWithCursor(
670                AppMode::Command,
671                CursorPosition::End
672            ))
673        );
674
675        // Note: SQL clause navigation (wa, oa, etc.) has been moved to the KeyChordHandler
676        // and is now accessed via chord sequences like 'cw' for WHERE, 'co' for ORDER BY.
677        // These are tested separately in the chord handler tests.
678    }
679
680    #[test]
681    fn test_sort_key_mapping() {
682        let mut mapper = KeyMapper::new();
683        let context = ActionContext {
684            mode: AppMode::Results,
685            selection_mode: SelectionMode::Row,
686            has_results: true,
687            has_filter: false,
688            has_search: false,
689            row_count: 100,
690            column_count: 10,
691            current_row: 0,
692            current_column: 0,
693        };
694
695        // Test 's' for standalone sort action (this was the original issue)
696        let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE);
697        let action = mapper.map_key(key, &context);
698        assert_eq!(action, Some(Action::Sort(None)));
699    }
700
701    #[test]
702    fn test_vim_go_to_top() {
703        let mut mapper = KeyMapper::new();
704        let context = ActionContext {
705            mode: AppMode::Results,
706            selection_mode: SelectionMode::Row,
707            has_results: true,
708            has_filter: false,
709            has_search: false,
710            row_count: 100,
711            column_count: 10,
712            current_row: 0,
713            current_column: 0,
714        };
715
716        // Test 'gg' for go to top (vim-style)
717        let key_g1 = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
718        let action_g1 = mapper.map_key(key_g1, &context);
719        assert_eq!(action_g1, None); // First 'g' starts collecting command
720
721        let key_g2 = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
722        let action_gg = mapper.map_key(key_g2, &context);
723        assert_eq!(action_gg, Some(Action::Navigate(NavigateAction::Home)));
724    }
725
726    #[test]
727    fn test_bug_reproduction_s_key_not_found() {
728        // This test reproduces the original bug where 's' key mapping wasn't found
729        let mut mapper = KeyMapper::new();
730        let context = ActionContext {
731            mode: AppMode::Results,
732            selection_mode: SelectionMode::Row,
733            has_results: true,
734            has_filter: false,
735            has_search: false,
736            row_count: 100,
737            column_count: 10,
738            current_row: 0,
739            current_column: 0,
740        };
741
742        // Before the fix, this would return None because 's' was being intercepted
743        // by vim command logic. After the fix, it should return Sort action.
744        let key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE);
745        let action = mapper.map_key(key, &context);
746
747        // This should NOT be None - the bug was that map_key returned None
748        assert!(
749            action.is_some(),
750            "Bug reproduction: 's' key should map to an action, not return None"
751        );
752        assert_eq!(
753            action,
754            Some(Action::Sort(None)),
755            "Bug reproduction: 's' key should map to Sort action"
756        );
757    }
758}