From fd7a4242f7bcfd9d8eaf34341a56a591646c84e6 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 19 Oct 2026 16:26:36 +0200 Subject: [PATCH] feat: add vim query navigation --- TODO.md | 3 +- src/app.rs | 66 +++++++++++++++++++++++++++++++++++ src/connection.rs | 89 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 2 ++ 4 files changed, 159 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 5c16f5e..44d7ff9 100644 --- a/TODO.md +++ b/TODO.md @@ -21,4 +21,5 @@ - [x] info panel should also pop up when pressing `i` on a child entry of that database entry in the sidebar (instead of only when the parent/database connection is hovered) - [x] `line:col` indicator in the bottom-right status bar should never disappear but instead display "N/A" in that case. - [x] database type indicator should be moved down just left of the "line:col" indicator like in the attached image -- [ ] introduce vim navigation inside the query input field. \ No newline at end of file +- [x] introduce vim navigation inside the query input field (`hjkl`, `w/b/e`, `0/^/$`, `gg/G`; bindings listed in `?`). +- [ ] bugfix: ctrl+enter current doesn't work for executing sql, only F5 does. \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 4261840..6ab9d8b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -34,6 +34,7 @@ pub struct App { show_schema: bool, query: Input, query_insert: bool, + query_g: bool, query_highlights: sql::Highlights, query_result: Option, error: Option, @@ -59,6 +60,7 @@ impl App { show_schema: false, query: Input::default(), query_insert: false, + query_g: false, query_highlights: Vec::new(), query_result: None, error: None, @@ -80,6 +82,7 @@ impl App { } pub(crate) fn leave_query_insert(&mut self) -> bool { + self.query_g = false; std::mem::replace(&mut self.query_insert, false) } @@ -116,6 +119,7 @@ impl App { pub(crate) fn handle_query_key(&mut self, key: KeyEvent) { let previous_len = self.query.value.len(); + let pending_g = std::mem::take(&mut self.query_g); match key.code { KeyCode::F(5) => self.execute_query(), KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(), @@ -140,6 +144,20 @@ impl App { 'j' => Some(KeyCode::Down), 'k' => Some(KeyCode::Up), 'l' => Some(KeyCode::Right), + '0' => Some(KeyCode::Home), + '$' => Some(KeyCode::End), + '^' | 'w' | 'b' | 'e' | 'G' => { + self.query.vim_motion(character); + None + } + 'g' => { + if pending_g { + self.query.vim_motion('g'); + } else { + self.query_g = true; + } + None + } 'a' => { let (line, column) = self.query_position(); self.query @@ -527,6 +545,54 @@ mod tests { assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2)); } + #[test] + fn vim_query_motions_preserve_sql_and_handle_unicode() -> crate::Result<()> { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let file = tempfile::NamedTempFile::new()?; + let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?; + let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); + app.handle_query_key(key('i')); + app.paste_query(" SELECT café,界\n\n FROM data"); + app.leave_query_insert(); + for (motion, expected) in [ + ('g', (3, 12)), + ('g', (1, 1)), + ('^', (1, 3)), + ('e', (1, 8)), + ('w', (1, 10)), + ('e', (1, 13)), + ('w', (1, 14)), + ('w', (1, 15)), + ('w', (3, 3)), + ('b', (1, 15)), + ('b', (1, 14)), + ('b', (1, 10)), + ('0', (1, 1)), + ('$', (1, 16)), + ('G', (3, 3)), + ('e', (3, 6)), + ('w', (3, 8)), + ('w', (3, 12)), + ('w', (3, 12)), + ] { + app.handle_query_key(key(motion)); + assert_eq!(app.query_position(), expected, "motion {motion}"); + assert!(!app.query_insert_mode()); + } + assert_eq!(app.query.value, " SELECT café,界\n\n FROM data"); + app.handle_query_key(key('i')); + for character in ['w', 'b', 'e', '0', '^', '$', 'g', 'G'] { + app.handle_query_key(key(character)); + } + assert_eq!(app.query.value, " SELECT café,界\n\n FROM datawbe0^$gG"); + app.leave_query_insert(); + app.handle_query_key(key('g')); + app.handle_query_key(key('0')); + app.handle_query_key(key('g')); + assert_eq!(app.query_position(), (3, 1)); + Ok(()) + } + #[test] fn query_modes_preserve_literal_text_and_navigation() -> crate::Result<()> { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; diff --git a/src/connection.rs b/src/connection.rs index 8b62702..b5bc0ba 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -336,6 +336,9 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { Query editor\n\ i/a/I/A Enter Insert mode (at/after/start/end)\n\ hjkl / arrows Move cursor in Normal mode\n\ + w/b/e Next word / previous word / word end\n\ + 0 / ^ / $ Line start / first nonblank / line end\n\ + gg / G First / last line\n\ Home/End Start / end of line in either mode\n\ F5 / Ctrl-Enter Execute query in either mode\n\ Type / paste Enter SQL in Insert mode\n\ @@ -926,6 +929,92 @@ impl From<&str> for Input { } impl Input { + pub(crate) fn vim_motion(&mut self, motion: char) { + let class = |character: char| { + if character.is_whitespace() { + 0 + } else if character.is_alphanumeric() || character == '_' { + 1 + } else { + 2 + } + }; + match motion { + 'g' => self.cursor = 0, + 'G' => { + self.cursor = self.value.rfind('\n').map_or(0, |index| index + 1); + self.vim_motion('^'); + } + '^' => { + self.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + self.cursor += self.value[self.cursor..] + .chars() + .take_while(|character| *character != '\n' && character.is_whitespace()) + .map(char::len_utf8) + .sum::(); + } + 'w' => { + let mut characters = self.value[self.cursor..].chars().peekable(); + if let Some(first) = characters.peek().copied() { + let current = class(first); + while characters + .peek() + .is_some_and(|character| class(*character) == current) + { + self.cursor += characters.next().unwrap().len_utf8(); + } + while characters + .peek() + .is_some_and(|character| character.is_whitespace()) + { + self.cursor += characters.next().unwrap().len_utf8(); + } + } + } + 'b' => { + let mut characters = self.value[..self.cursor].char_indices().rev().peekable(); + while characters + .peek() + .is_some_and(|(_, character)| character.is_whitespace()) + { + self.cursor = characters.next().unwrap().0; + } + if let Some((_, first)) = characters.peek().copied() { + let current = class(first); + while characters + .peek() + .is_some_and(|(_, character)| class(*character) == current) + { + self.cursor = characters.next().unwrap().0; + } + } + } + 'e' => { + let mut characters = self.value[self.cursor..].char_indices(); + characters.next(); + let mut characters = characters.peekable(); + while characters + .peek() + .is_some_and(|(_, character)| character.is_whitespace()) + { + characters.next(); + } + if let Some((offset, first)) = characters.next() { + let current = class(first); + let start = self.cursor; + self.cursor = start + offset; + while characters + .peek() + .is_some_and(|(_, character)| class(*character) == current) + { + self.cursor = start + characters.next().unwrap().0; + } + } + } + _ => {} + } + } + pub(crate) fn position(&self) -> (usize, usize) { let prefix = &self.value[..self.cursor]; ( diff --git a/src/main.rs b/src/main.rs index a2cf7c9..30d6d74 100644 --- a/src/main.rs +++ b/src/main.rs @@ -55,6 +55,8 @@ Navigation: Explorer and rows/schema bottom borders align; table names appear in the top-right border, with page/column status below. Read errors appear above the query editor. Query editor starts in NORMAL: i/a/I/A enters INSERT; hjkl/arrows move. + NORMAL also supports w/b/e (word motions), 0/^/$ (line motions), and + gg/G (first/last line). Words distinguish identifiers from punctuation. In INSERT, type SQL; Enter inserts a newline. Arrow keys, Home/End, Backspace/Delete, Ctrl-U (clear), and multiline pasting are supported. Esc leaves INSERT first; Esc in NORMAL returns to the explorer.