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::{ use ratatui::{
Frame, Frame,
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
@@ -9,10 +9,18 @@ use ratatui::{
use crate::{ use crate::{
Result, Result,
db::{Column, Database, PAGE_SIZE, Page, Relation}, connection::Input,
db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation},
error_text, error_text,
}; };
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Focus {
Explorer,
Query,
Rows,
}
pub struct App { pub struct App {
db: Database, db: Database,
pub(crate) relations: Vec<Relation>, pub(crate) relations: Vec<Relation>,
@@ -24,6 +32,8 @@ pub struct App {
schema_state: TableState, schema_state: TableState,
column_offset: usize, column_offset: usize,
show_schema: bool, show_schema: bool,
query: Input,
query_result: Option<QueryResult>,
error: Option<String>, error: Option<String>,
} }
@@ -45,6 +55,8 @@ impl App {
schema_state: TableState::default(), schema_state: TableState::default(),
column_offset: 0, column_offset: 0,
show_schema: false, show_schema: false,
query: Input::default(),
query_result: None,
error: None, error: None,
}; };
app.open_relation(selected); app.open_relation(selected);
@@ -57,7 +69,7 @@ impl App {
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN), KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
KeyCode::End | KeyCode::Char('G') => self.move_selection(isize::MAX), 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.show_schema = !self.show_schema;
self.column_offset = 0; self.column_offset = 0;
} }
@@ -68,16 +80,22 @@ impl App {
let count = if self.show_schema { let count = if self.show_schema {
5 5
} else { } else {
self.columns.len() self.query_result
.as_ref()
.map_or(self.columns.len(), |result| result.headers.len())
}; };
if self.column_offset + 1 < count { if self.column_offset + 1 < count {
self.column_offset += 1; 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); 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); self.load_page(self.page_index - 1);
} }
KeyCode::Char('r') => self.refresh(), 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) { fn move_selection(&mut self, delta: isize) {
if self.show_schema { if self.show_schema {
let next = moved_index(self.schema_state.selected(), self.columns.len(), delta); let next = moved_index(self.schema_state.selected(), self.columns.len(), delta);
self.schema_state.select(next); self.schema_state.select(next);
} else { } 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); self.rows_state.select(next);
} }
} }
pub(crate) fn open_relation(&mut self, index: Option<usize>) { pub(crate) fn open_relation(&mut self, index: Option<usize>) {
self.active = index; self.active = index;
self.query_result = None;
self.columns.clear(); self.columns.clear();
self.page = Page { self.page = Page {
rows: Vec::new(), 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 { if area.width < 38 || area.height < 10 {
frame.render_widget( 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, area,
); );
return; return;
@@ -174,8 +224,18 @@ impl App {
.style(Style::default().fg(Color::Cyan)), .style(Style::default().fg(Color::Cyan)),
header, header,
); );
let content = body; let [query, content] =
let title = if self.show_schema { 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 " " Schema · s for rows "
} else { } else {
" Rows · s for schema " " Rows · s for schema "
@@ -183,7 +243,9 @@ impl App {
let count = if self.show_schema { let count = if self.show_schema {
5 5
} else { } 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) let visible = usize::from(content.width.saturating_sub(4) / 21)
.max(1) .max(1)
@@ -195,7 +257,20 @@ impl App {
Constraint::Length(20) 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]; let relation = &self.relations[index];
if self.show_schema { if self.show_schema {
format!( format!(
@@ -228,8 +303,12 @@ impl App {
" No readable relations. r to refresh.".into() " No readable relations. r to refresh.".into()
}; };
let content_block = let content_block =
panel(title, focused).title_bottom(Line::from(description).left_aligned()); panel(title, focus == Focus::Rows).title_bottom(Line::from(description).left_aligned());
if self.active.is_none() { 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( frame.render_widget(
Paragraph::new("Choose a relation and press Enter.").block(content_block), Paragraph::new("Choose a relation and press Enter.").block(content_block),
content, content,
@@ -259,7 +338,7 @@ impl App {
.row_highlight_style(selected_style()) .row_highlight_style(selected_style())
.highlight_symbol("› "); .highlight_symbol("› ");
frame.render_stateful_widget(table, content, &mut self.schema_state); 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( frame.render_widget(
Paragraph::new(if self.error.is_some() { Paragraph::new(if self.error.is_some() {
"Could not load rows. See error above." "Could not load rows. See error above."
@@ -270,13 +349,13 @@ impl App {
content, content,
); );
} else { } else {
let headers = self let headers = (self.column_offset..self.column_offset + visible).map(|index| {
.columns self.query_result.as_ref().map_or_else(
.iter() || self.columns[index].label.as_str(),
.skip(self.column_offset) |result| result.headers[index].as_str(),
.take(visible) )
.map(|column| column.label.as_str()); });
let rows = self.page.rows.iter().map(|row| { let rows = page.rows.iter().map(|row| {
Row::new( Row::new(
row.iter() row.iter()
.skip(self.column_offset) .skip(self.column_offset)
@@ -353,6 +432,37 @@ 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 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] #[test]
fn relation_status_is_in_the_bottom_border() -> crate::Result<()> { fn relation_status_is_in_the_bottom_border() -> crate::Result<()> {
let file = tempfile::NamedTempFile::new()?; let file = tempfile::NamedTempFile::new()?;
@@ -364,7 +474,7 @@ mod tests {
for (schema, error) in [(false, None), (true, None), (false, Some("Read failed"))] { for (schema, error) in [(false, None), (true, None), (false, Some("Read failed"))] {
app.show_schema = schema; app.show_schema = schema;
app.error = error.map(str::to_owned); 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 buffer = terminal.backend().buffer();
let border: String = (0..100).map(|x| buffer[(x, 19)].symbol()).collect(); let border: String = (0..100).map(|x| buffer[(x, 19)].symbol()).collect();
assert!(border.starts_with("└ main.comments [table]"), "{border}"); assert!(border.starts_with("└ main.comments [table]"), "{border}");
+124 -35
View File
@@ -13,14 +13,19 @@ use ratatui::{
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}, widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
}; };
use crate::{Result, app::App, db::Database, error_text}; use crate::{
Result,
app::{App, Focus},
db::Database,
error_text,
};
pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<()> { pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<()> {
let mut profiles = Profiles::open(&config_path()?)?; let mut profiles = Profiles::open(&config_path()?)?;
let mut browser = initial.map(App::new).transpose()?; let mut browser = initial.map(App::new).transpose()?;
let mut selection = let mut selection =
ListState::default().with_selected((!profiles.entries.is_empty()).then_some(0)); ListState::default().with_selected((!profiles.entries.is_empty()).then_some(0));
let mut databases_focus = true; let mut focus = Focus::Explorer;
let mut active = None; let mut active = None;
let mut expanded = true; let mut expanded = true;
let mut error: Option<String> = None; let mut error: Option<String> = None;
@@ -34,9 +39,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
expanded, expanded,
); );
terminal.draw(|frame| { terminal.draw(|frame| {
let navigation = Paragraph::new( let navigation = Paragraph::new(if focus == Focus::Query && !show_form {
" Tab focus · Esc explorer · ↑↓/jk move · Enter open · s schema/rows · ←→/hl columns · n/p page · r refresh · c add database · q quit" " Tab/Shift-Tab focus · Esc explorer · Enter newline · F5/Ctrl-Enter execute · Ctrl-U clear · Ctrl-C quit"
).wrap(Wrap { trim: true }).style(Style::default().fg(Color::DarkGray)); } else {
" Tab/Shift-Tab focus · Esc explorer · ↑↓/jk move · Enter open · s schema/rows · ←→/hl columns · n/p page · r refresh · c add database · q quit"
}).wrap(Wrap { trim: true }).style(Style::default().fg(Color::DarkGray));
let help_height = navigation.line_count(frame.area().width) as u16; let help_height = navigation.line_count(frame.area().width) as u16;
let [main, help] = let [main, help] =
Layout::vertical([Constraint::Min(1), Constraint::Length(help_height)]).areas(frame.area()); Layout::vertical([Constraint::Min(1), Constraint::Length(help_height)]).areas(frame.area());
@@ -57,11 +64,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}); });
let list = List::new(items) let list = List::new(items)
.block(Block::bordered().title(" Explorer · c add ").border_style( .block(Block::bordered().title(" Explorer · c add ").border_style(
Style::default().fg(if databases_focus { Color::Cyan } else { Color::DarkGray }))) Style::default().fg(if focus == Focus::Explorer { Color::Cyan } else { Color::DarkGray })))
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White)); .highlight_style(Style::default().bg(Color::Blue).fg(Color::White));
frame.render_stateful_widget(list, sidebar, &mut selection); frame.render_stateful_widget(list, sidebar, &mut selection);
if let Some(browser) = &mut browser { if let Some(browser) = &mut browser {
browser.draw(frame, content, !databases_focus); browser.draw(frame, content, focus);
} else { } else {
frame.render_widget(Paragraph::new("Select a database and press Enter to connect.\n\nc: add a database · Tab: focus · q: quit").block(Block::bordered().title(" dbdash · READ ONLY ")), content); frame.render_widget(Paragraph::new("Select a database and press Enter to connect.\n\nc: add a database · Tab: focus · q: quit").block(Block::bordered().title(" dbdash · READ ONLY ")), content);
} }
@@ -97,7 +104,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}); });
selection.select(active); selection.select(active);
expanded = true; expanded = true;
databases_focus = true; focus = Focus::Explorer;
}) })
} else { } else {
profiles.save(&form).map(|()| { profiles.save(&form).map(|()| {
@@ -113,7 +120,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
profiles.entries.len() - 1, profiles.entries.len() - 1,
)) ))
})); }));
databases_focus = true; focus = Focus::Explorer;
}) })
}; };
match result { match result {
@@ -129,25 +136,32 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
} }
} else { } else {
match key.code { match key.code {
KeyCode::Esc => focus = Focus::Explorer,
KeyCode::Tab | KeyCode::BackTab if browser.is_some() => {
focus = match (focus, key.code == KeyCode::BackTab) {
(Focus::Explorer, false) | (Focus::Rows, true) => Focus::Query,
(Focus::Query, false) | (Focus::Explorer, true) => Focus::Rows,
_ => Focus::Explorer,
};
}
_ if focus == Focus::Query => {
if let Some(browser) = &mut browser {
browser.handle_query_key(key);
}
}
KeyCode::Char('q') => return Ok(()), KeyCode::Char('q') => return Ok(()),
KeyCode::Char('c') => { KeyCode::Char('c') => {
form = Form::default(); form = Form::default();
show_form = true; show_form = true;
} }
KeyCode::Esc => databases_focus = true, KeyCode::Enter if focus == Focus::Explorer => {
KeyCode::Tab | KeyCode::BackTab => {
if browser.is_some() {
databases_focus = !databases_focus;
}
}
KeyCode::Enter if databases_focus => {
if let Some(entry) = if let Some(entry) =
selection.selected().and_then(|index| entries.get(index)) selection.selected().and_then(|index| entries.get(index))
{ {
let index = match *entry { let index = match *entry {
ExplorerEntry::Relation(index) => { ExplorerEntry::Relation(index) => {
browser.as_mut().unwrap().open_relation(Some(index)); browser.as_mut().unwrap().open_relation(Some(index));
databases_focus = false; focus = Focus::Rows;
continue; continue;
} }
ExplorerEntry::Database(index) ExplorerEntry::Database(index)
@@ -171,7 +185,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
active = Some(index); active = Some(index);
selection.select(Some(index)); selection.select(Some(index));
expanded = true; expanded = true;
databases_focus = true; focus = Focus::Explorer;
error = None; error = None;
} }
Err(failure) => error = Some(error_text(failure.as_ref())), Err(failure) => error = Some(error_text(failure.as_ref())),
@@ -179,7 +193,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
} }
} }
} }
KeyCode::Left | KeyCode::Char('h') if databases_focus => { KeyCode::Left | KeyCode::Char('h') if focus == Focus::Explorer => {
if matches!( if matches!(
selection.selected().and_then(|index| entries.get(index)), selection.selected().and_then(|index| entries.get(index)),
Some(ExplorerEntry::Relation(_)) Some(ExplorerEntry::Relation(_))
@@ -194,31 +208,31 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
); );
} }
} }
KeyCode::Right | KeyCode::Char('l') if databases_focus => { KeyCode::Right | KeyCode::Char('l') if focus == Focus::Explorer => {
if selection.selected().and_then(|index| entries.get(index)) if selection.selected().and_then(|index| entries.get(index))
== Some(&ExplorerEntry::Database(active)) == Some(&ExplorerEntry::Database(active))
{ {
expanded = true; expanded = true;
} }
} }
KeyCode::Char('r') if databases_focus => { KeyCode::Char('r') if focus == Focus::Explorer => {
if let Some(browser) = &mut browser { if let Some(browser) = &mut browser {
browser.handle_key(key); browser.handle_key(key);
} }
} }
KeyCode::Down | KeyCode::Char('j') if databases_focus => { KeyCode::Down | KeyCode::Char('j') if focus == Focus::Explorer => {
selection.select_next() selection.select_next()
} }
KeyCode::Up | KeyCode::Char('k') if databases_focus => { KeyCode::Up | KeyCode::Char('k') if focus == Focus::Explorer => {
selection.select_previous() selection.select_previous()
} }
KeyCode::Home | KeyCode::Char('g') if databases_focus => { KeyCode::Home | KeyCode::Char('g') if focus == Focus::Explorer => {
selection.select_first() selection.select_first()
} }
KeyCode::End | KeyCode::Char('G') if databases_focus => { KeyCode::End | KeyCode::Char('G') if focus == Focus::Explorer => {
selection.select_last() selection.select_last()
} }
_ if !databases_focus => { _ if focus == Focus::Rows => {
if let Some(browser) = &mut browser { if let Some(browser) = &mut browser {
browser.handle_key(key); browser.handle_key(key);
} }
@@ -233,6 +247,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
input.paste(&text); input.paste(&text);
} }
} }
Event::Paste(text) if focus == Focus::Query => {
if let Some(browser) = &mut browser {
browser.paste_query(&text);
}
}
_ => {} _ => {}
} }
} }
@@ -617,8 +636,8 @@ impl Form {
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct Input { pub(crate) struct Input {
value: String, pub(crate) value: String,
cursor: usize, cursor: usize,
} }
@@ -632,7 +651,7 @@ impl From<&str> for Input {
} }
impl Input { impl Input {
fn edit(&mut self, key: KeyEvent) { pub(crate) fn edit(&mut self, key: KeyEvent) {
match key.code { match key.code {
KeyCode::Left => { KeyCode::Left => {
self.cursor = self.value[..self.cursor] self.cursor = self.value[..self.cursor]
@@ -646,8 +665,39 @@ impl Input {
.next() .next()
.map_or(0, char::len_utf8) .map_or(0, char::len_utf8)
} }
KeyCode::Home => self.cursor = 0, KeyCode::Up | KeyCode::Down => {
KeyCode::End => self.cursor = self.value.len(), let start = self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1);
let column = self.value[start..self.cursor].chars().count();
let target = if key.code == KeyCode::Up {
start
.checked_sub(1)
.map(|end| (self.value[..end].rfind('\n').map_or(0, |i| i + 1), end))
} else {
self.value[self.cursor..].find('\n').map(|i| {
let start = self.cursor + i + 1;
let end = self.value[start..]
.find('\n')
.map_or(self.value.len(), |i| start + i);
(start, end)
})
};
if let Some((start, end)) = target {
self.cursor = start
+ self.value[start..end]
.chars()
.take(column)
.map(char::len_utf8)
.sum::<usize>();
}
}
KeyCode::Home => {
self.cursor = self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1);
}
KeyCode::End => {
self.cursor += self.value[self.cursor..]
.find('\n')
.unwrap_or(self.value.len() - self.cursor);
}
KeyCode::Backspace if self.cursor > 0 => { KeyCode::Backspace if self.cursor > 0 => {
let previous = self.value[..self.cursor] let previous = self.value[..self.cursor]
.char_indices() .char_indices()
@@ -686,21 +736,41 @@ impl Input {
self.cursor += text.len(); self.cursor += text.len();
} }
fn draw(&self, frame: &mut Frame, area: Rect, label: &str, secret: bool, focused: bool) { pub(crate) fn paste_multiline(&mut self, value: &str) {
let text: String = value
.chars()
.map(|character| if character == '\t' { ' ' } else { character })
.filter(|character| *character == '\n' || !character.is_control())
.collect();
self.value.insert_str(self.cursor, &text);
self.cursor += text.len();
}
pub(crate) fn draw(
&self,
frame: &mut Frame,
area: Rect,
label: &str,
secret: bool,
focused: bool,
) {
let text: Cow<'_, str> = if secret { let text: Cow<'_, str> = if secret {
Cow::Owned("•".repeat(self.value.chars().count())) Cow::Owned("•".repeat(self.value.chars().count()))
} else { } else {
Cow::Borrowed(&self.value) Cow::Borrowed(&self.value)
}; };
let prefix = &self.value[..self.cursor];
let row = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX);
let row_offset = row.saturating_sub(area.height.saturating_sub(3));
let before_cursor = if secret { let before_cursor = if secret {
self.value[..self.cursor].chars().count() self.value[..self.cursor].chars().count()
} else { } else {
Line::raw(&self.value[..self.cursor]).width() Line::raw(prefix.rsplit('\n').next().unwrap_or("")).width()
}; };
let cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX); let cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX);
let offset = cursor.saturating_sub(area.width.saturating_sub(3)); let offset = cursor.saturating_sub(area.width.saturating_sub(3));
frame.render_widget( frame.render_widget(
Paragraph::new(text).scroll((0, offset)).block( Paragraph::new(text).scroll((row_offset, offset)).block(
Block::bordered() Block::bordered()
.title(label) .title(label)
.border_style(Style::default().fg(if focused { .border_style(Style::default().fg(if focused {
@@ -712,7 +782,8 @@ impl Input {
area, area,
); );
if focused && area.width > 2 && area.height > 2 { if focused && area.width > 2 && area.height > 2 {
frame.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1)); frame
.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1 + row - row_offset));
} }
} }
} }
@@ -765,6 +836,24 @@ mod tests {
assert_eq!(input.cursor, 0); assert_eq!(input.cursor, 0);
} }
#[test]
fn multiline_editing_preserves_lines_and_unicode() {
let mut input = Input::default();
input.paste_multiline("aé界\nxy\nz\r\n\t\u{1b}");
assert_eq!(input.value, "aé界\nxy\nz\n ");
for code in [KeyCode::Up, KeyCode::Up, KeyCode::Up] {
input.edit(KeyEvent::new(code, KeyModifiers::NONE));
}
assert_eq!(&input.value[..input.cursor], "a");
input.edit(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
input.edit(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(&input.value[..input.cursor], "aé界\nxy");
input.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert_eq!(input.value, "aé界xy\nz\n ");
assert_eq!(input.cursor, "aé界".len());
}
#[test] #[test]
fn password_never_appears_in_the_connection_screen() -> Result<()> { fn password_never_appears_in_the_connection_screen() -> Result<()> {
let mut form = Form { let mut form = Form {
+104
View File
@@ -46,6 +46,11 @@ pub struct Page {
pub has_next: bool, pub has_next: bool,
} }
pub struct QueryResult {
pub headers: Vec<String>,
pub page: Page,
}
impl Database { impl Database {
pub fn connect(connection: &str) -> Result<Self> { pub fn connect(connection: &str) -> Result<Self> {
Self::connect_postgres(connection.parse()?) Self::connect_postgres(connection.parse()?)
@@ -157,6 +162,59 @@ impl Database {
.collect()) .collect())
} }
pub fn query(&mut self, sql: &str) -> Result<QueryResult> {
let sql = sql.trim();
let sql = sql.strip_suffix(';').unwrap_or(sql).trim_end();
let client = match &mut self.backend {
Backend::Postgres(client) => client,
Backend::Sqlite(connection) => return sqlite::query(connection, sql),
};
let mut transaction = client.build_transaction().read_only(true).start()?;
transaction.batch_execute("SET LOCAL statement_timeout = '5s'")?;
let statement = transaction.prepare(&format!(
"SELECT * FROM (\n{sql}\n) AS query_result LIMIT 0"
))?;
let headers = statement
.columns()
.iter()
.map(|column| display_text(column.name()))
.collect::<Vec<_>>();
let aliases = (0..headers.len())
.map(|index| format!("c{index}"))
.collect::<Vec<_>>();
let projection = aliases
.iter()
.map(|alias| format!("query_result.{alias}::text"))
.collect::<Vec<_>>()
.join(", ");
let alias_list = if aliases.is_empty() {
String::new()
} else {
format!("({})", aliases.join(", "))
};
let result = transaction.query(
&format!(
"SELECT {projection} FROM (\n{sql}\n) AS query_result{alias_list} LIMIT {}",
PAGE_SIZE + 1
),
&[],
)?;
let has_next = result.len() > PAGE_SIZE;
let rows = result
.into_iter()
.take(PAGE_SIZE)
.map(|row| {
(0..headers.len())
.map(|index| row.get::<_, Option<&str>>(index).map(display_text))
.collect()
})
.collect();
Ok(QueryResult {
headers,
page: Page { rows, has_next },
})
}
pub fn page(&mut self, relation: &Relation, columns: &[Column], page: usize) -> Result<Page> { pub fn page(&mut self, relation: &Relation, columns: &[Column], page: usize) -> Result<Page> {
let client = match &mut self.backend { let client = match &mut self.backend {
Backend::Postgres(client) => client, Backend::Postgres(client) => client,
@@ -319,9 +377,55 @@ mod tests {
let page = db.page(empty, &columns, 0)?; let page = db.page(empty, &columns, 0)?;
assert!(page.rows.is_empty()); assert!(page.rows.is_empty());
assert!(!page.has_next); assert!(!page.has_next);
let query = db.query(&format!(
" \nWITH data AS (SELECT * FROM {schema}.\"odd\"\"table\")
SELECT * FROM data ORDER BY id; \n"
))?;
assert_eq!(query.headers[1], "odd\"column");
assert_eq!(query.page.rows, first.rows);
assert!(query.page.has_next);
let duplicate = db.query("SELECT 1 AS same, NULL::text AS same")?;
assert_eq!(duplicate.headers, ["same", "same"]);
assert_eq!(duplicate.page.rows, [vec![Some("1".into()), None]]);
assert!(!duplicate.page.has_next);
let empty = db.query("SELECT 1 AS empty WHERE false")?;
assert_eq!(empty.headers, ["empty"]);
assert!(empty.page.rows.is_empty());
assert!(!empty.page.has_next);
let controls = db.query("SELECT E'\\033[2J' AS \"line\nheader\" -- trailing comment")?;
assert_eq!(controls.headers, ["line\\nheader"]);
assert_eq!(controls.page.rows[0][0].as_deref(), Some("\\u{1b}[2J"));
assert!(db.query("SELECT 1; SELECT 2").is_err());
assert!(
db.query(&format!("DELETE FROM {schema}.empty_table RETURNING id"))
.is_err()
);
assert!(
db.query(&format!(
"WITH deleted AS (DELETE FROM {schema}.empty_table RETURNING id)
SELECT * FROM deleted"
))
.is_err()
);
db.query("SELECT set_config('default_transaction_read_only', 'off', false)")?;
let settings = db.query("SELECT current_setting('default_transaction_read_only')")?;
assert_eq!(settings.page.rows[0][0].as_deref(), Some("on"));
if let Backend::Postgres(client) = &mut db.backend {
client.batch_execute(
"SET default_transaction_read_only = off; SET statement_timeout = 0",
)?;
}
let settings = db.query(
"SELECT current_setting('transaction_read_only'), current_setting('statement_timeout')",
)?;
assert_eq!(
settings.page.rows[0],
[Some("on".into()), Some("5s".into())]
);
let Backend::Postgres(client) = &mut db.backend else { let Backend::Postgres(client) = &mut db.backend else {
unreachable!() unreachable!()
}; };
client.batch_execute("SET default_transaction_read_only = on")?;
let error = client let error = client
.batch_execute(&format!("DELETE FROM {schema}.empty_table")) .batch_execute(&format!("DELETE FROM {schema}.empty_table"))
.unwrap_err(); .unwrap_err();
+86 -2
View File
@@ -2,7 +2,9 @@ use std::{fmt::Write, path::Path, time::Duration};
use rusqlite::{Connection, OpenFlags, params, types::ValueRef}; use rusqlite::{Connection, OpenFlags, params, types::ValueRef};
use super::{Column, PAGE_SIZE, Page, Relation, display_text, page_offset, quote_identifier}; use super::{
Column, PAGE_SIZE, Page, QueryResult, Relation, display_text, page_offset, quote_identifier,
};
use crate::Result; use crate::Result;
pub(super) fn open(path: &Path) -> Result<Connection> { pub(super) fn open(path: &Path) -> Result<Connection> {
@@ -109,6 +111,26 @@ pub(super) fn page(
); );
let mut statement = connection.prepare(&sql)?; let mut statement = connection.prepare(&sql)?;
let mut query = statement.query(params![(PAGE_SIZE + 1) as i64, page_offset(page)?])?; let mut query = statement.query(params![(PAGE_SIZE + 1) as i64, page_offset(page)?])?;
read_page(&mut query, columns.len())
}
pub(super) fn query(connection: &Connection, sql: &str) -> Result<QueryResult> {
let original = connection.prepare(sql)?;
let headers = original
.column_names()
.into_iter()
.map(display_text)
.collect();
let mut statement = connection.prepare(&format!(
"SELECT * FROM (\n{sql}\n) AS query_result LIMIT {}",
PAGE_SIZE + 1
))?;
let column_count = statement.column_count();
let page = read_page(&mut statement.query([])?, column_count)?;
Ok(QueryResult { headers, page })
}
fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize) -> Result<Page> {
let mut rows = Vec::with_capacity(PAGE_SIZE); let mut rows = Vec::with_capacity(PAGE_SIZE);
while let Some(row) = query.next()? { while let Some(row) = query.next()? {
if rows.len() == PAGE_SIZE { if rows.len() == PAGE_SIZE {
@@ -117,7 +139,7 @@ pub(super) fn page(
has_next: true, has_next: true,
}); });
} }
let values = (0..columns.len()) let values = (0..column_count)
.map(|index| { .map(|index| {
Ok(match row.get_ref(index)? { Ok(match row.get_ref(index)? {
ValueRef::Null => None, ValueRef::Null => None,
@@ -154,6 +176,68 @@ mod tests {
use super::*; use super::*;
use crate::db::{Backend, Database}; use crate::db::{Backend, Database};
#[test]
fn query_sqlite_read_only() -> Result<()> {
let directory = tempfile::tempdir()?;
let path = directory.path().join("queries.sqlite");
let writer = Connection::open(&path)?;
writer
.execute_batch("CREATE TABLE saved (value INTEGER); INSERT INTO saved VALUES (7);")?;
let mut db = Database::open_sqlite(&path)?;
let result = db.query(
" \nSELECT NULL AS \"same\", X'00FF' AS \"same\", char(27) || '[2J' AS \"line
break\", 2.5 AS amount; \n",
)?;
assert_eq!(result.headers, ["same", "same", "line\\nbreak", "amount"]);
assert_eq!(
result.page.rows,
vec![vec![
None,
Some("X'00FF'".into()),
Some("\\u{1b}[2J".into()),
Some("2.5".into())
]]
);
assert!(!result.page.has_next);
let empty = db.query("SELECT value AS original FROM saved WHERE 0")?;
assert_eq!(empty.headers, ["original"]);
assert!(empty.page.rows.is_empty());
assert!(!empty.page.has_next);
let result = db.query(
"WITH RECURSIVE numbers(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 101
) SELECT n FROM numbers -- trailing comment",
)?;
assert_eq!(result.headers, ["n"]);
assert_eq!(result.page.rows.len(), PAGE_SIZE);
assert_eq!(result.page.rows[99][0].as_deref(), Some("100"));
assert!(result.page.has_next);
let exact = db.query(
"WITH RECURSIVE numbers(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 100
) SELECT n FROM numbers",
)?;
assert_eq!(exact.page.rows.len(), PAGE_SIZE);
assert!(!exact.page.has_next);
for sql in [
"DELETE FROM saved",
"UPDATE saved SET value = 8 RETURNING value",
"DROP TABLE saved",
"SELECT value FROM saved; DELETE FROM saved",
"PRAGMA query_only = OFF",
] {
assert!(db.query(sql).is_err(), "accepted write: {sql}");
assert_eq!(
db.query("SELECT value FROM saved")?.page.rows,
vec![vec![Some("7".into())]]
);
}
Ok(())
}
#[test] #[test]
fn inspect_sqlite_read_only() -> Result<()> { fn inspect_sqlite_read_only() -> Result<()> {
let directory = tempfile::tempdir()?; let directory = tempfile::tempdir()?;
+12 -5
View File
@@ -26,23 +26,30 @@ Expansion never runs shell commands. Quote CLI paths to let dbdash expand them.
PostgreSQL URLs and libpq-style key=value strings are accepted. PostgreSQL URLs and libpq-style key=value strings are accepted.
Use sslmode=require for mandatory TLS; certificates and hostnames are verified. Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
Use sslmode=disable only for trusted local connections. Use sslmode=disable only for trusted local connections.
The inspector needs 70×12 terminal cells; the PostgreSQL window needs 74×20. The inspector needs 70×14 terminal cells; the PostgreSQL window needs 74×20.
Navigation: Navigation:
Tab/Shift-Tab Switch between explorer and rows/schema (Esc returns to explorer) Tab/Shift-Tab Cycle explorer, query editor, and rows/schema (Esc returns to explorer)
Up/Down, j/k Move selection; Enter connects/expands a database or opens a relation Up/Down, j/k Move selection; Enter connects/expands a database or opens a relation
Home/End, g/G First / last item Home/End, g/G First / last item
s Toggle rows and column schema s Toggle rows and column schema
Left/Right Collapse/expand in explorer; scroll columns in rows/schema (h/l too) Left/Right Collapse/expand in explorer; scroll columns in rows/schema (h/l too)
n/p, PgDn/Up Next / previous 100-row page n/p, PgDn/Up Next / previous 100-row relation page
c Add a configured database in a floating window c Add a configured database in a floating window
r Refresh relations and the active relation r Refresh relations and the active relation
Connected databases show their tables and views beneath them in the explorer. Connected databases show their tables and views beneath them in the explorer.
Enter on the connected database collapses/expands it without reconnecting. Enter on the connected database collapses/expands it without reconnecting.
Navigation help spans the bottom as one line, wrapping only when needed. Navigation help spans the bottom as one line, wrapping only when needed.
The query editor sits above rows/results; the explorer spans both panes.
Explorer and rows/schema bottom borders align; relation/page/column status Explorer and rows/schema bottom borders align; relation/page/column status
appears in the rows/schema border. Read errors appear above that pane. appears in the rows/schema border. Read errors appear above the query editor.
q / Ctrl-C Quit In the query editor, type SQL directly; Enter inserts a newline. Arrow keys,
Home/End, Backspace/Delete, Ctrl-U (clear), and multiline pasting are supported.
F5 / Ctrl-Enter executes one SELECT/CTE on the current connection, showing up
to 100 rows below. Results report truncation; use SQL LIMIT/OFFSET for more.
Failed queries keep the previous results. Opening a relation restores browsing
without clearing the query text. Query text is not saved across connections.
q / Ctrl-C Quit (q is literal text in the query editor)
New database window: Up/Down chooses a provider; Enter starts editing. New database window: Up/Down chooses a provider; Enter starts editing.
Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is focused. Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is focused.
Enter saves without connecting, including when the database is offline. Enter saves without connecting, including when the database is offline.