feat: add vim query navigation
This commit is contained in:
@@ -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] 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] `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
|
- [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.
|
- [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.
|
||||||
+66
@@ -34,6 +34,7 @@ pub struct App {
|
|||||||
show_schema: bool,
|
show_schema: bool,
|
||||||
query: Input,
|
query: Input,
|
||||||
query_insert: bool,
|
query_insert: bool,
|
||||||
|
query_g: bool,
|
||||||
query_highlights: sql::Highlights,
|
query_highlights: sql::Highlights,
|
||||||
query_result: Option<QueryResult>,
|
query_result: Option<QueryResult>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
@@ -59,6 +60,7 @@ impl App {
|
|||||||
show_schema: false,
|
show_schema: false,
|
||||||
query: Input::default(),
|
query: Input::default(),
|
||||||
query_insert: false,
|
query_insert: false,
|
||||||
|
query_g: false,
|
||||||
query_highlights: Vec::new(),
|
query_highlights: Vec::new(),
|
||||||
query_result: None,
|
query_result: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -80,6 +82,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn leave_query_insert(&mut self) -> bool {
|
pub(crate) fn leave_query_insert(&mut self) -> bool {
|
||||||
|
self.query_g = false;
|
||||||
std::mem::replace(&mut self.query_insert, 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) {
|
pub(crate) fn handle_query_key(&mut self, key: KeyEvent) {
|
||||||
let previous_len = self.query.value.len();
|
let previous_len = self.query.value.len();
|
||||||
|
let pending_g = std::mem::take(&mut self.query_g);
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::F(5) => self.execute_query(),
|
KeyCode::F(5) => self.execute_query(),
|
||||||
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(),
|
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(),
|
||||||
@@ -140,6 +144,20 @@ impl App {
|
|||||||
'j' => Some(KeyCode::Down),
|
'j' => Some(KeyCode::Down),
|
||||||
'k' => Some(KeyCode::Up),
|
'k' => Some(KeyCode::Up),
|
||||||
'l' => Some(KeyCode::Right),
|
'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' => {
|
'a' => {
|
||||||
let (line, column) = self.query_position();
|
let (line, column) = self.query_position();
|
||||||
self.query
|
self.query
|
||||||
@@ -527,6 +545,54 @@ mod tests {
|
|||||||
assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2));
|
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]
|
#[test]
|
||||||
fn query_modes_preserve_literal_text_and_navigation() -> crate::Result<()> {
|
fn query_modes_preserve_literal_text_and_navigation() -> crate::Result<()> {
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|||||||
@@ -336,6 +336,9 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
|
|||||||
Query editor\n\
|
Query editor\n\
|
||||||
i/a/I/A Enter Insert mode (at/after/start/end)\n\
|
i/a/I/A Enter Insert mode (at/after/start/end)\n\
|
||||||
hjkl / arrows Move cursor in Normal mode\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\
|
Home/End Start / end of line in either mode\n\
|
||||||
F5 / Ctrl-Enter Execute query in either mode\n\
|
F5 / Ctrl-Enter Execute query in either mode\n\
|
||||||
Type / paste Enter SQL in Insert mode\n\
|
Type / paste Enter SQL in Insert mode\n\
|
||||||
@@ -926,6 +929,92 @@ impl From<&str> for Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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::<usize>();
|
||||||
|
}
|
||||||
|
'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) {
|
pub(crate) fn position(&self) -> (usize, usize) {
|
||||||
let prefix = &self.value[..self.cursor];
|
let prefix = &self.value[..self.cursor];
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ Navigation:
|
|||||||
Explorer and rows/schema bottom borders align; table names appear in the
|
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.
|
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.
|
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,
|
In INSERT, type SQL; Enter inserts a newline. Arrow keys, Home/End,
|
||||||
Backspace/Delete, Ctrl-U (clear), and multiline pasting are supported.
|
Backspace/Delete, Ctrl-U (clear), and multiline pasting are supported.
|
||||||
Esc leaves INSERT first; Esc in NORMAL returns to the explorer.
|
Esc leaves INSERT first; Esc in NORMAL returns to the explorer.
|
||||||
|
|||||||
Reference in New Issue
Block a user