feat: add modal query editing and vamp-style status bars
This commit is contained in:
+248
-49
@@ -33,6 +33,7 @@ pub struct App {
|
||||
column_offset: usize,
|
||||
show_schema: bool,
|
||||
query: Input,
|
||||
query_insert: bool,
|
||||
query_highlights: sql::Highlights,
|
||||
query_result: Option<QueryResult>,
|
||||
error: Option<String>,
|
||||
@@ -57,6 +58,7 @@ impl App {
|
||||
column_offset: 0,
|
||||
show_schema: false,
|
||||
query: Input::default(),
|
||||
query_insert: false,
|
||||
query_highlights: Vec::new(),
|
||||
query_result: None,
|
||||
error: None,
|
||||
@@ -65,6 +67,26 @@ impl App {
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
pub(crate) fn provider(&self) -> &'static str {
|
||||
self.db.name()
|
||||
}
|
||||
|
||||
pub(crate) fn database_name(&self) -> &str {
|
||||
self.db.display_name()
|
||||
}
|
||||
|
||||
pub(crate) fn query_insert_mode(&self) -> bool {
|
||||
self.query_insert
|
||||
}
|
||||
|
||||
pub(crate) fn leave_query_insert(&mut self) -> bool {
|
||||
std::mem::replace(&mut self.query_insert, false)
|
||||
}
|
||||
|
||||
pub(crate) fn query_position(&self) -> (usize, usize) {
|
||||
self.query.position()
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
|
||||
@@ -75,21 +97,8 @@ impl App {
|
||||
self.show_schema = !self.show_schema;
|
||||
self.column_offset = 0;
|
||||
}
|
||||
KeyCode::Left | KeyCode::Char('h') => {
|
||||
self.column_offset = self.column_offset.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Right | KeyCode::Char('l') => {
|
||||
let count = if self.show_schema {
|
||||
5
|
||||
} else {
|
||||
self.query_result
|
||||
.as_ref()
|
||||
.map_or(self.columns.len(), |result| result.headers.len())
|
||||
};
|
||||
if self.column_offset + 1 < count {
|
||||
self.column_offset += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Left | KeyCode::Char('h') => self.move_column(-1),
|
||||
KeyCode::Right | KeyCode::Char('l') => self.move_column(1),
|
||||
KeyCode::Char('n') | KeyCode::PageDown
|
||||
if self.query_result.is_none() && !self.show_schema && self.page.has_next =>
|
||||
{
|
||||
@@ -110,13 +119,54 @@ impl App {
|
||||
match key.code {
|
||||
KeyCode::F(5) => self.execute_query(),
|
||||
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(),
|
||||
KeyCode::Enter => self.query.paste_multiline("\n"),
|
||||
_ => self.query.edit(key),
|
||||
KeyCode::Esc => {
|
||||
self.leave_query_insert();
|
||||
}
|
||||
KeyCode::Enter if self.query_insert => self.query.paste_multiline("\n"),
|
||||
_ if self.query_insert => self.query.edit(key),
|
||||
KeyCode::Left
|
||||
| KeyCode::Right
|
||||
| KeyCode::Up
|
||||
| KeyCode::Down
|
||||
| KeyCode::Home
|
||||
| KeyCode::End => self.query.edit(key),
|
||||
KeyCode::Char(character)
|
||||
if !key
|
||||
.modifiers
|
||||
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||
{
|
||||
let movement = match character {
|
||||
'h' => Some(KeyCode::Left),
|
||||
'j' => Some(KeyCode::Down),
|
||||
'k' => Some(KeyCode::Up),
|
||||
'l' => Some(KeyCode::Right),
|
||||
'a' => {
|
||||
let (line, column) = self.query_position();
|
||||
self.query
|
||||
.value
|
||||
.split('\n')
|
||||
.nth(line - 1)
|
||||
.filter(|text| column <= text.chars().count())
|
||||
.map(|_| KeyCode::Right)
|
||||
}
|
||||
'I' => Some(KeyCode::Home),
|
||||
'A' => Some(KeyCode::End),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(code) = movement {
|
||||
self.query.edit(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
}
|
||||
self.query_insert = matches!(character, 'i' | 'a' | 'I' | 'A');
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.highlight_query(previous_len);
|
||||
}
|
||||
|
||||
pub(crate) fn paste_query(&mut self, text: &str) {
|
||||
if !self.query_insert {
|
||||
return;
|
||||
}
|
||||
let previous_len = self.query.value.len();
|
||||
self.query.paste_multiline(text);
|
||||
self.highlight_query(previous_len);
|
||||
@@ -133,7 +183,8 @@ impl App {
|
||||
match self.db.query(&self.query.value) {
|
||||
Ok(result) => {
|
||||
self.rows_state = TableState::default()
|
||||
.with_selected((!result.page.rows.is_empty()).then_some(0));
|
||||
.with_selected((!result.page.rows.is_empty()).then_some(0))
|
||||
.with_selected_column((!result.headers.is_empty()).then_some(0));
|
||||
self.column_offset = 0;
|
||||
self.show_schema = false;
|
||||
self.query_result = Some(result);
|
||||
@@ -157,6 +208,26 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn column_count(&self) -> usize {
|
||||
if self.show_schema {
|
||||
5
|
||||
} else {
|
||||
self.query_result
|
||||
.as_ref()
|
||||
.map_or(self.columns.len(), |result| result.headers.len())
|
||||
}
|
||||
}
|
||||
|
||||
fn move_column(&mut self, delta: isize) {
|
||||
let count = self.column_count();
|
||||
let state = if self.show_schema {
|
||||
&mut self.schema_state
|
||||
} else {
|
||||
&mut self.rows_state
|
||||
};
|
||||
state.select_column(moved_index(state.selected_column(), count, delta));
|
||||
}
|
||||
|
||||
pub(crate) fn open_relation(&mut self, index: Option<usize>) {
|
||||
self.active = index;
|
||||
self.query_result = None;
|
||||
@@ -176,6 +247,8 @@ impl App {
|
||||
self.columns = columns;
|
||||
self.schema_state
|
||||
.select((!self.columns.is_empty()).then_some(0));
|
||||
self.schema_state
|
||||
.select_column((!self.columns.is_empty()).then_some(0));
|
||||
self.load_page(0);
|
||||
}
|
||||
Err(error) => self.error = Some(error_text(error.as_ref())),
|
||||
@@ -188,8 +261,13 @@ impl App {
|
||||
// ponytail: synchronous reads block input; use a worker if responsiveness matters.
|
||||
match self.db.page(&self.relations[active], &self.columns, index) {
|
||||
Ok(page) => {
|
||||
self.rows_state =
|
||||
TableState::default().with_selected((!page.rows.is_empty()).then_some(0));
|
||||
self.rows_state = TableState::default()
|
||||
.with_selected((!page.rows.is_empty()).then_some(0))
|
||||
.with_selected_column(
|
||||
self.rows_state
|
||||
.selected_column()
|
||||
.or_else(|| (!self.columns.is_empty()).then_some(0)),
|
||||
);
|
||||
self.page = page;
|
||||
self.page_index = index;
|
||||
self.error = None;
|
||||
@@ -217,26 +295,16 @@ impl App {
|
||||
pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focus: Focus) {
|
||||
if area.width < 38 || area.height < 10 {
|
||||
frame.render_widget(
|
||||
Paragraph::new("Enlarge terminal to at least 70×14. Ctrl-C to quit."),
|
||||
Paragraph::new("Enlarge terminal to at least 70×17. Ctrl-C to quit."),
|
||||
area,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let [header, error_area, body] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
let [error_area, body] = Layout::vertical([
|
||||
Constraint::Length(u16::from(self.error.is_some())),
|
||||
Constraint::Min(4),
|
||||
])
|
||||
.areas(area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from_iter([
|
||||
" dbdash │ ",
|
||||
self.db.name(),
|
||||
" │ READ ONLY",
|
||||
]))
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
header,
|
||||
);
|
||||
let [query, content] =
|
||||
Layout::vertical([Constraint::Ratio(1, 2), Constraint::Min(4)]).areas(body);
|
||||
self.query.draw(
|
||||
@@ -254,16 +322,20 @@ impl App {
|
||||
} else {
|
||||
" Rows · s for schema "
|
||||
};
|
||||
let count = if self.show_schema {
|
||||
5
|
||||
let count = self.column_count();
|
||||
let capacity = usize::from(content.width.saturating_sub(2) / 21).max(1);
|
||||
let selected = if self.show_schema {
|
||||
self.schema_state.selected_column()
|
||||
} else {
|
||||
self.query_result
|
||||
.as_ref()
|
||||
.map_or(self.columns.len(), |result| result.headers.len())
|
||||
self.rows_state.selected_column()
|
||||
};
|
||||
let visible = usize::from(content.width.saturating_sub(4) / 21)
|
||||
.max(1)
|
||||
.min(count.saturating_sub(self.column_offset));
|
||||
if let Some(selected) = selected {
|
||||
self.column_offset = self.column_offset.min(selected);
|
||||
if selected >= self.column_offset + capacity {
|
||||
self.column_offset = selected + 1 - capacity;
|
||||
}
|
||||
}
|
||||
let visible = capacity.min(count.saturating_sub(self.column_offset));
|
||||
let widths = (0..visible).map(|index| {
|
||||
if index + 1 == visible {
|
||||
Constraint::Min(1)
|
||||
@@ -349,9 +421,11 @@ impl App {
|
||||
.style(header_style()),
|
||||
)
|
||||
.block(content_block)
|
||||
.row_highlight_style(selected_style())
|
||||
.highlight_symbol("› ");
|
||||
.cell_highlight_style(selected_style());
|
||||
self.schema_state
|
||||
.select_column(selected.map(|column| column - self.column_offset));
|
||||
frame.render_stateful_widget(table, content, &mut self.schema_state);
|
||||
self.schema_state.select_column(selected);
|
||||
} else if page.rows.is_empty() {
|
||||
frame.render_widget(
|
||||
Paragraph::new(if self.error.is_some() {
|
||||
@@ -387,9 +461,11 @@ impl App {
|
||||
let table = Table::new(rows, widths)
|
||||
.header(Row::new(headers).style(header_style()))
|
||||
.block(content_block)
|
||||
.row_highlight_style(selected_style())
|
||||
.highlight_symbol("› ");
|
||||
.cell_highlight_style(selected_style());
|
||||
self.rows_state
|
||||
.select_column(selected.map(|column| column - self.column_offset));
|
||||
frame.render_stateful_widget(table, content, &mut self.rows_state);
|
||||
self.rows_state.select_column(selected);
|
||||
}
|
||||
if let Some(error) = &self.error {
|
||||
frame.render_widget(
|
||||
@@ -446,6 +522,124 @@ mod tests {
|
||||
assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_modes_preserve_literal_text_and_navigation() -> 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);
|
||||
assert!(!app.query_insert_mode());
|
||||
app.paste_query("ignored");
|
||||
app.handle_query_key(key('q'));
|
||||
assert!(app.query.value.is_empty());
|
||||
assert!(!app.leave_query_insert());
|
||||
app.handle_query_key(key('i'));
|
||||
for character in ['q', 'i', 'h'] {
|
||||
app.handle_query_key(key(character));
|
||||
}
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
app.paste_query("界é");
|
||||
assert_eq!(app.query.value, "qih\n界é");
|
||||
assert_eq!(app.query_position(), (2, 3));
|
||||
assert!(app.leave_query_insert());
|
||||
app.paste_query("ignored");
|
||||
app.handle_query_key(key('h'));
|
||||
assert_eq!(app.query_position(), (2, 2));
|
||||
app.handle_query_key(key('k'));
|
||||
assert_eq!(app.query_position(), (1, 2));
|
||||
app.handle_query_key(key('a'));
|
||||
assert!(app.query_insert_mode());
|
||||
assert_eq!(app.query_position(), (1, 3));
|
||||
app.leave_query_insert();
|
||||
app.handle_query_key(key('I'));
|
||||
assert_eq!(app.query_position(), (1, 1));
|
||||
assert!(app.query_insert_mode());
|
||||
app.leave_query_insert();
|
||||
app.handle_query_key(key('A'));
|
||||
assert_eq!(app.query_position(), (1, 4));
|
||||
assert!(app.query_insert_mode());
|
||||
app.leave_query_insert();
|
||||
app.handle_query_key(key('a'));
|
||||
assert_eq!(app.query_position(), (1, 4));
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(!app.query_insert_mode());
|
||||
assert_eq!(app.query.value, "qih\n界é");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_shortcuts_work_in_both_query_modes() -> 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())?)?;
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
app.paste_query("SELECT\n42");
|
||||
for insert in [true, false] {
|
||||
if !insert {
|
||||
app.leave_query_insert();
|
||||
}
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE),
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
|
||||
] {
|
||||
app.query_result = None;
|
||||
app.handle_query_key(key);
|
||||
assert_eq!(app.query_insert_mode(), insert);
|
||||
assert_eq!(
|
||||
app.query_result.as_ref().unwrap().page.rows[0][0].as_deref(),
|
||||
Some("42")
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_selection_scrolls_and_survives_paging_and_schema_switches() -> crate::Result<()> {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{Terminal, backend::TestBackend, style::Color};
|
||||
let file = tempfile::NamedTempFile::new()?;
|
||||
rusqlite::Connection::open(file.path())?.execute_batch(
|
||||
"CREATE TABLE cells (a, b, c, d, e, f);
|
||||
WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<101)
|
||||
INSERT INTO cells SELECT x, x, x, x, x, x FROM n;",
|
||||
)?;
|
||||
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?;
|
||||
let mut terminal = Terminal::new(TestBackend::new(60, 20))?;
|
||||
let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((0, 0)));
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?;
|
||||
assert_eq!(terminal.backend().buffer()[(1, 12)].bg, Color::Blue);
|
||||
assert_ne!(terminal.backend().buffer()[(22, 12)].bg, Color::Blue);
|
||||
app.handle_key(key('l'));
|
||||
app.handle_key(key('l'));
|
||||
app.handle_key(key('j'));
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?;
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((1, 2)));
|
||||
assert_eq!(app.column_offset, 1);
|
||||
assert_eq!(terminal.backend().buffer()[(22, 13)].bg, Color::Blue);
|
||||
assert_ne!(terminal.backend().buffer()[(1, 13)].bg, Color::Blue);
|
||||
assert_ne!(terminal.backend().buffer()[(22, 12)].bg, Color::Blue);
|
||||
app.handle_key(key('n'));
|
||||
assert_eq!(app.page_index, 1);
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((0, 2)));
|
||||
app.handle_key(key('s'));
|
||||
app.handle_key(key('l'));
|
||||
app.handle_key(key('j'));
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?;
|
||||
assert_eq!(app.schema_state.selected_cell(), Some((1, 1)));
|
||||
assert_eq!(app.column_offset, 0);
|
||||
assert_eq!(terminal.backend().buffer()[(22, 13)].bg, Color::Blue);
|
||||
assert_ne!(terminal.backend().buffer()[(1, 13)].bg, Color::Blue);
|
||||
app.handle_key(key('s'));
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?;
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((0, 2)));
|
||||
assert_eq!(app.column_offset, 1);
|
||||
app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||||
assert_eq!(app.rows_state.selected_column(), Some(1));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_colors_survive_editing_and_scrolling() -> crate::Result<()> {
|
||||
use super::{App, Focus};
|
||||
@@ -454,17 +648,18 @@ mod tests {
|
||||
let file = tempfile::NamedTempFile::new()?;
|
||||
let mut app = App::new(crate::db::Database::open_sqlite(file.path())?)?;
|
||||
let mut terminal = Terminal::new(TestBackend::new(60, 14))?;
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
app.paste_query("SELECT '界', 42;\n-- comment");
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?;
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_eq!(buffer[(1, 2)].fg, Color::Cyan);
|
||||
assert_eq!(buffer[(8, 2)].fg, Color::Green);
|
||||
assert_eq!(buffer[(1, 3)].fg, Color::DarkGray);
|
||||
assert_eq!(buffer[(1, 1)].fg, Color::Cyan);
|
||||
assert_eq!(buffer[(8, 1)].fg, Color::Green);
|
||||
assert_eq!(buffer[(1, 2)].fg, Color::DarkGray);
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE));
|
||||
app.paste_query("unfinished\nstring");
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?;
|
||||
assert_eq!(terminal.backend().buffer()[(1, 3)].fg, Color::Green);
|
||||
assert_eq!(terminal.backend().buffer()[(1, 2)].fg, Color::Green);
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
app.paste_query(&format!("{}--{}", "-- line\n".repeat(12), "x".repeat(90)));
|
||||
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?;
|
||||
@@ -484,11 +679,15 @@ mod tests {
|
||||
"CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (9);",
|
||||
)?;
|
||||
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?;
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
||||
app.paste_query("SELECT '界é' AS note,\nNULL AS missing UNION ALL SELECT 'next', 2;");
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
|
||||
let result = app.query_result.as_ref().unwrap();
|
||||
assert_eq!(result.headers, ["note", "missing"]);
|
||||
assert_eq!(result.page.rows[0], [Some("界é".into()), None]);
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((0, 0)));
|
||||
app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
|
||||
assert_eq!(app.rows_state.selected_cell(), Some((0, 1)));
|
||||
app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
assert_eq!(app.rows_state.selected(), Some(1));
|
||||
app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||
@@ -529,7 +728,7 @@ mod tests {
|
||||
"rows 1–1"
|
||||
}));
|
||||
if let Some(error) = error {
|
||||
let message: String = (0..100).map(|x| buffer[(x, 1)].symbol()).collect();
|
||||
let message: String = (0..100).map(|x| buffer[(x, 0)].symbol()).collect();
|
||||
assert!(message.starts_with(error), "{message}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user