feat: add query editor above result rows
This commit is contained in:
+134
-24
@@ -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}");
|
||||
|
||||
+124
-35
@@ -13,14 +13,19 @@ use ratatui::{
|
||||
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<()> {
|
||||
let mut profiles = Profiles::open(&config_path()?)?;
|
||||
let mut browser = initial.map(App::new).transpose()?;
|
||||
let mut selection =
|
||||
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 expanded = true;
|
||||
let mut error: Option<String> = None;
|
||||
@@ -34,9 +39,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
expanded,
|
||||
);
|
||||
terminal.draw(|frame| {
|
||||
let navigation = Paragraph::new(
|
||||
" 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 navigation = Paragraph::new(if focus == Focus::Query && !show_form {
|
||||
" Tab/Shift-Tab focus · Esc explorer · Enter newline · F5/Ctrl-Enter execute · Ctrl-U clear · Ctrl-C quit"
|
||||
} 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 [main, help] =
|
||||
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)
|
||||
.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));
|
||||
frame.render_stateful_widget(list, sidebar, &mut selection);
|
||||
if let Some(browser) = &mut browser {
|
||||
browser.draw(frame, content, !databases_focus);
|
||||
browser.draw(frame, content, focus);
|
||||
} 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);
|
||||
}
|
||||
@@ -97,7 +104,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
});
|
||||
selection.select(active);
|
||||
expanded = true;
|
||||
databases_focus = true;
|
||||
focus = Focus::Explorer;
|
||||
})
|
||||
} else {
|
||||
profiles.save(&form).map(|()| {
|
||||
@@ -113,7 +120,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
profiles.entries.len() - 1,
|
||||
))
|
||||
}));
|
||||
databases_focus = true;
|
||||
focus = Focus::Explorer;
|
||||
})
|
||||
};
|
||||
match result {
|
||||
@@ -129,25 +136,32 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
}
|
||||
} else {
|
||||
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('c') => {
|
||||
form = Form::default();
|
||||
show_form = true;
|
||||
}
|
||||
KeyCode::Esc => databases_focus = true,
|
||||
KeyCode::Tab | KeyCode::BackTab => {
|
||||
if browser.is_some() {
|
||||
databases_focus = !databases_focus;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter if databases_focus => {
|
||||
KeyCode::Enter if focus == Focus::Explorer => {
|
||||
if let Some(entry) =
|
||||
selection.selected().and_then(|index| entries.get(index))
|
||||
{
|
||||
let index = match *entry {
|
||||
ExplorerEntry::Relation(index) => {
|
||||
browser.as_mut().unwrap().open_relation(Some(index));
|
||||
databases_focus = false;
|
||||
focus = Focus::Rows;
|
||||
continue;
|
||||
}
|
||||
ExplorerEntry::Database(index)
|
||||
@@ -171,7 +185,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
active = Some(index);
|
||||
selection.select(Some(index));
|
||||
expanded = true;
|
||||
databases_focus = true;
|
||||
focus = Focus::Explorer;
|
||||
error = None;
|
||||
}
|
||||
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!(
|
||||
selection.selected().and_then(|index| entries.get(index)),
|
||||
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))
|
||||
== Some(&ExplorerEntry::Database(active))
|
||||
{
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') if databases_focus => {
|
||||
KeyCode::Char('r') if focus == Focus::Explorer => {
|
||||
if let Some(browser) = &mut browser {
|
||||
browser.handle_key(key);
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if databases_focus => {
|
||||
KeyCode::Down | KeyCode::Char('j') if focus == Focus::Explorer => {
|
||||
selection.select_next()
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') if databases_focus => {
|
||||
KeyCode::Up | KeyCode::Char('k') if focus == Focus::Explorer => {
|
||||
selection.select_previous()
|
||||
}
|
||||
KeyCode::Home | KeyCode::Char('g') if databases_focus => {
|
||||
KeyCode::Home | KeyCode::Char('g') if focus == Focus::Explorer => {
|
||||
selection.select_first()
|
||||
}
|
||||
KeyCode::End | KeyCode::Char('G') if databases_focus => {
|
||||
KeyCode::End | KeyCode::Char('G') if focus == Focus::Explorer => {
|
||||
selection.select_last()
|
||||
}
|
||||
_ if !databases_focus => {
|
||||
_ if focus == Focus::Rows => {
|
||||
if let Some(browser) = &mut browser {
|
||||
browser.handle_key(key);
|
||||
}
|
||||
@@ -233,6 +247,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
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)]
|
||||
struct Input {
|
||||
value: String,
|
||||
pub(crate) struct Input {
|
||||
pub(crate) value: String,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
@@ -632,7 +651,7 @@ impl From<&str> for Input {
|
||||
}
|
||||
|
||||
impl Input {
|
||||
fn edit(&mut self, key: KeyEvent) {
|
||||
pub(crate) fn edit(&mut self, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Left => {
|
||||
self.cursor = self.value[..self.cursor]
|
||||
@@ -646,8 +665,39 @@ impl Input {
|
||||
.next()
|
||||
.map_or(0, char::len_utf8)
|
||||
}
|
||||
KeyCode::Home => self.cursor = 0,
|
||||
KeyCode::End => self.cursor = self.value.len(),
|
||||
KeyCode::Up | KeyCode::Down => {
|
||||
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 => {
|
||||
let previous = self.value[..self.cursor]
|
||||
.char_indices()
|
||||
@@ -686,21 +736,41 @@ impl Input {
|
||||
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 {
|
||||
Cow::Owned("•".repeat(self.value.chars().count()))
|
||||
} else {
|
||||
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 {
|
||||
self.value[..self.cursor].chars().count()
|
||||
} 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 offset = cursor.saturating_sub(area.width.saturating_sub(3));
|
||||
frame.render_widget(
|
||||
Paragraph::new(text).scroll((0, offset)).block(
|
||||
Paragraph::new(text).scroll((row_offset, offset)).block(
|
||||
Block::bordered()
|
||||
.title(label)
|
||||
.border_style(Style::default().fg(if focused {
|
||||
@@ -712,7 +782,8 @@ impl Input {
|
||||
area,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn password_never_appears_in_the_connection_screen() -> Result<()> {
|
||||
let mut form = Form {
|
||||
|
||||
@@ -46,6 +46,11 @@ pub struct Page {
|
||||
pub has_next: bool,
|
||||
}
|
||||
|
||||
pub struct QueryResult {
|
||||
pub headers: Vec<String>,
|
||||
pub page: Page,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn connect(connection: &str) -> Result<Self> {
|
||||
Self::connect_postgres(connection.parse()?)
|
||||
@@ -157,6 +162,59 @@ impl Database {
|
||||
.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> {
|
||||
let client = match &mut self.backend {
|
||||
Backend::Postgres(client) => client,
|
||||
@@ -319,9 +377,55 @@ mod tests {
|
||||
let page = db.page(empty, &columns, 0)?;
|
||||
assert!(page.rows.is_empty());
|
||||
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 {
|
||||
unreachable!()
|
||||
};
|
||||
client.batch_execute("SET default_transaction_read_only = on")?;
|
||||
let error = client
|
||||
.batch_execute(&format!("DELETE FROM {schema}.empty_table"))
|
||||
.unwrap_err();
|
||||
|
||||
+86
-2
@@ -2,7 +2,9 @@ use std::{fmt::Write, path::Path, time::Duration};
|
||||
|
||||
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;
|
||||
|
||||
pub(super) fn open(path: &Path) -> Result<Connection> {
|
||||
@@ -109,6 +111,26 @@ pub(super) fn page(
|
||||
);
|
||||
let mut statement = connection.prepare(&sql)?;
|
||||
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);
|
||||
while let Some(row) = query.next()? {
|
||||
if rows.len() == PAGE_SIZE {
|
||||
@@ -117,7 +139,7 @@ pub(super) fn page(
|
||||
has_next: true,
|
||||
});
|
||||
}
|
||||
let values = (0..columns.len())
|
||||
let values = (0..column_count)
|
||||
.map(|index| {
|
||||
Ok(match row.get_ref(index)? {
|
||||
ValueRef::Null => None,
|
||||
@@ -154,6 +176,68 @@ mod tests {
|
||||
use super::*;
|
||||
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]
|
||||
fn inspect_sqlite_read_only() -> Result<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
|
||||
+12
-5
@@ -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.
|
||||
Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
|
||||
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:
|
||||
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
|
||||
Home/End, g/G First / last item
|
||||
s Toggle rows and column schema
|
||||
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
|
||||
r Refresh relations and the active relation
|
||||
Connected databases show their tables and views beneath them in the explorer.
|
||||
Enter on the connected database collapses/expands it without reconnecting.
|
||||
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
|
||||
appears in the rows/schema border. Read errors appear above that pane.
|
||||
q / Ctrl-C Quit
|
||||
appears in the rows/schema border. Read errors appear above the query editor.
|
||||
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.
|
||||
Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is focused.
|
||||
Enter saves without connecting, including when the database is offline.
|
||||
|
||||
Reference in New Issue
Block a user