feat: add query editor above result rows

This commit is contained in:
Valdemar Lorenzen
2026-10-19 14:04:36 +02:00
parent b15460d5f8
commit 49a3dbddcf
5 changed files with 460 additions and 66 deletions
+134 -24
View File
@@ -1,4 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
@@ -9,10 +9,18 @@ use ratatui::{
use crate::{
Result,
db::{Column, Database, PAGE_SIZE, Page, Relation},
connection::Input,
db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation},
error_text,
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Focus {
Explorer,
Query,
Rows,
}
pub struct App {
db: Database,
pub(crate) relations: Vec<Relation>,
@@ -24,6 +32,8 @@ pub struct App {
schema_state: TableState,
column_offset: usize,
show_schema: bool,
query: Input,
query_result: Option<QueryResult>,
error: Option<String>,
}
@@ -45,6 +55,8 @@ impl App {
schema_state: TableState::default(),
column_offset: 0,
show_schema: false,
query: Input::default(),
query_result: None,
error: None,
};
app.open_relation(selected);
@@ -57,7 +69,7 @@ impl App {
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
KeyCode::End | KeyCode::Char('G') => self.move_selection(isize::MAX),
KeyCode::Char('s') => {
KeyCode::Char('s') if self.query_result.is_none() => {
self.show_schema = !self.show_schema;
self.column_offset = 0;
}
@@ -68,16 +80,22 @@ impl App {
let count = if self.show_schema {
5
} else {
self.columns.len()
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::Char('n') | KeyCode::PageDown if !self.show_schema && self.page.has_next => {
KeyCode::Char('n') | KeyCode::PageDown
if self.query_result.is_none() && !self.show_schema && self.page.has_next =>
{
self.load_page(self.page_index + 1);
}
KeyCode::Char('p') | KeyCode::PageUp if !self.show_schema && self.page_index > 0 => {
KeyCode::Char('p') | KeyCode::PageUp
if self.query_result.is_none() && !self.show_schema && self.page_index > 0 =>
{
self.load_page(self.page_index - 1);
}
KeyCode::Char('r') => self.refresh(),
@@ -85,18 +103,50 @@ impl App {
}
}
pub(crate) fn handle_query_key(&mut self, key: KeyEvent) {
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),
}
}
pub(crate) fn paste_query(&mut self, text: &str) {
self.query.paste_multiline(text);
}
fn execute_query(&mut self) {
match self.db.query(&self.query.value) {
Ok(result) => {
self.rows_state = TableState::default()
.with_selected((!result.page.rows.is_empty()).then_some(0));
self.column_offset = 0;
self.show_schema = false;
self.query_result = Some(result);
self.error = None;
}
Err(error) => self.error = Some(error_text(error.as_ref())),
}
}
fn move_selection(&mut self, delta: isize) {
if self.show_schema {
let next = moved_index(self.schema_state.selected(), self.columns.len(), delta);
self.schema_state.select(next);
} else {
let next = moved_index(self.rows_state.selected(), self.page.rows.len(), delta);
let count = self
.query_result
.as_ref()
.map_or(self.page.rows.len(), |result| result.page.rows.len());
let next = moved_index(self.rows_state.selected(), count, delta);
self.rows_state.select(next);
}
}
pub(crate) fn open_relation(&mut self, index: Option<usize>) {
self.active = index;
self.query_result = None;
self.columns.clear();
self.page = Page {
rows: Vec::new(),
@@ -151,10 +201,10 @@ impl App {
}
}
pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focused: bool) {
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×12. q to quit."),
Paragraph::new("Enlarge terminal to at least 70×14. Ctrl-C to quit."),
area,
);
return;
@@ -174,8 +224,18 @@ impl App {
.style(Style::default().fg(Color::Cyan)),
header,
);
let content = body;
let title = if self.show_schema {
let [query, content] =
Layout::vertical([Constraint::Ratio(1, 2), Constraint::Min(4)]).areas(body);
self.query.draw(
frame,
query,
" Query · F5 execute ",
false,
focus == Focus::Query,
);
let title = if self.query_result.is_some() {
" Results "
} else if self.show_schema {
" Schema · s for rows "
} else {
" Rows · s for schema "
@@ -183,7 +243,9 @@ impl App {
let count = if self.show_schema {
5
} else {
self.columns.len()
self.query_result
.as_ref()
.map_or(self.columns.len(), |result| result.headers.len())
};
let visible = usize::from(content.width.saturating_sub(4) / 21)
.max(1)
@@ -195,7 +257,20 @@ impl App {
Constraint::Length(20)
}
});
let description = if let Some(index) = self.active {
let description = if let Some(result) = &self.query_result {
format!(
" Query · {} rows{} · columns {}–{}/{}",
result.page.rows.len(),
if result.page.has_next {
" (first 100)"
} else {
""
},
if count > 0 { self.column_offset + 1 } else { 0 },
self.column_offset + visible,
count,
)
} else if let Some(index) = self.active {
let relation = &self.relations[index];
if self.show_schema {
format!(
@@ -228,8 +303,12 @@ impl App {
" No readable relations. r to refresh.".into()
};
let content_block =
panel(title, focused).title_bottom(Line::from(description).left_aligned());
if self.active.is_none() {
panel(title, focus == Focus::Rows).title_bottom(Line::from(description).left_aligned());
let page = self
.query_result
.as_ref()
.map_or(&self.page, |result| &result.page);
if self.active.is_none() && self.query_result.is_none() {
frame.render_widget(
Paragraph::new("Choose a relation and press Enter.").block(content_block),
content,
@@ -259,7 +338,7 @@ impl App {
.row_highlight_style(selected_style())
.highlight_symbol("› ");
frame.render_stateful_widget(table, content, &mut self.schema_state);
} else if self.page.rows.is_empty() {
} else if page.rows.is_empty() {
frame.render_widget(
Paragraph::new(if self.error.is_some() {
"Could not load rows. See error above."
@@ -270,13 +349,13 @@ impl App {
content,
);
} else {
let headers = self
.columns
.iter()
.skip(self.column_offset)
.take(visible)
.map(|column| column.label.as_str());
let rows = self.page.rows.iter().map(|row| {
let headers = (self.column_offset..self.column_offset + visible).map(|index| {
self.query_result.as_ref().map_or_else(
|| self.columns[index].label.as_str(),
|result| result.headers[index].as_str(),
)
});
let rows = page.rows.iter().map(|row| {
Row::new(
row.iter()
.skip(self.column_offset)
@@ -353,6 +432,37 @@ mod tests {
assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2));
}
#[test]
fn queries_replace_rows_and_preserve_results_on_error() -> crate::Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let file = tempfile::NamedTempFile::new()?;
rusqlite::Connection::open(file.path())?.execute_batch(
"CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (9);",
)?;
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?;
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]);
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));
app.paste_query("SELECT missing FROM nowhere");
app.handle_query_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(app.error.is_some());
assert_eq!(
app.query_result.as_ref().unwrap().page.rows[0][0].as_deref(),
Some("界é")
);
app.open_relation(Some(0));
assert!(app.query_result.is_none());
assert!(app.error.is_none());
assert_eq!(app.page.rows, [vec![Some("9".into())]]);
assert_eq!(app.query.value, "SELECT missing FROM nowhere");
Ok(())
}
#[test]
fn relation_status_is_in_the_bottom_border() -> crate::Result<()> {
let file = tempfile::NamedTempFile::new()?;
@@ -364,7 +474,7 @@ mod tests {
for (schema, error) in [(false, None), (true, None), (false, Some("Read failed"))] {
app.show_schema = schema;
app.error = error.map(str::to_owned);
terminal.draw(|frame| app.draw(frame, frame.area(), true))?;
terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?;
let buffer = terminal.backend().buffer();
let border: String = (0..100).map(|x| buffer[(x, 19)].symbol()).collect();
assert!(border.starts_with("└ main.comments [table]"), "{border}");