Files
dbdash/src/app.rs
T
2026-10-21 10:53:39 +02:00

1158 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Cell, Paragraph, Row, Table, TableState},
};
use crate::{
Result,
connection::Input,
db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation},
error_text, sql,
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Focus {
Explorer,
Query,
Rows,
}
pub struct App {
db: Database,
pub(crate) relations: Vec<Relation>,
active: Option<usize>,
columns: Vec<Column>,
page: Page,
page_index: usize,
rows_state: TableState,
selection_anchor: Option<(usize, usize)>,
copy_headers: Option<bool>,
copy_status: Option<String>,
schema_state: TableState,
column_offset: usize,
show_schema: bool,
query: Input,
query_insert: bool,
query_g: bool,
query_highlights: sql::Highlights,
query_result: Option<QueryResult>,
error: Option<String>,
}
impl App {
pub fn new(mut db: Database) -> Result<Self> {
let relations = db.relations()?;
let selected = (!relations.is_empty()).then_some(0);
let mut app = Self {
db,
relations,
active: None,
columns: Vec::new(),
page: Page {
rows: Vec::new(),
has_next: false,
},
page_index: 0,
rows_state: TableState::default(),
selection_anchor: None,
copy_headers: None,
copy_status: None,
schema_state: TableState::default(),
column_offset: 0,
show_schema: false,
query: Input::default(),
query_insert: false,
query_g: false,
query_highlights: Vec::new(),
query_result: None,
error: None,
};
app.open_relation(selected);
Ok(app)
}
pub(crate) fn provider(&self) -> &'static str {
self.db.name()
}
pub(crate) fn access_mode(&self) -> crate::db::AccessMode {
self.db.access_mode()
}
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 {
self.query_g = false;
std::mem::replace(&mut self.query_insert, false)
}
pub(crate) fn query_position(&self) -> (usize, usize) {
self.query.position()
}
pub(crate) fn handle_rows_command(&mut self, key: KeyEvent) -> bool {
let pending = self.copy_headers.take();
if self.show_schema {
return false;
}
if key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& !(key.code == KeyCode::Char('v') && key.modifiers == KeyModifiers::CONTROL)
{
return false;
}
if let Some(headers) = pending {
match key.code {
KeyCode::Char(format @ ('m' | 'c')) => {
if let Some(text) = self.selection_text(headers, format == 'm') {
match copy_to_clipboard(&text) {
Ok(()) => {
self.error = None;
self.copy_status = Some(format!(
" Copied {} {} headers ",
if format == 'm' { "Markdown" } else { "CSV" },
if headers { "with" } else { "without" },
));
}
Err(error) => {
self.error = Some(format!("Clipboard: {}", error_text(error.as_ref())));
}
}
}
return true;
}
KeyCode::Esc => return true,
_ => {}
}
}
match key.code {
KeyCode::Char('v') => {
self.copy_status = None;
self.selection_anchor = if self.selection_anchor.is_some() {
None
} else {
self.rows_state.selected_cell()
};
true
}
KeyCode::Char(character @ ('y' | 'Y')) => {
self.copy_status = None;
if self.selection_bounds().is_some() {
self.copy_headers = Some(character == 'y');
}
true
}
KeyCode::Esc if self.selection_anchor.is_some() => {
self.selection_anchor = None;
self.copy_status = None;
true
}
_ => false,
}
}
fn clear_selection(&mut self) {
self.selection_anchor = None;
self.copy_headers = None;
self.copy_status = None;
}
fn selection_bounds(&self) -> Option<(usize, usize, usize, usize)> {
if self.show_schema {
return None;
}
let (row, column) = self.rows_state.selected_cell()?;
let (anchor_row, anchor_column) = self.selection_anchor.unwrap_or((row, column));
Some((
row.min(anchor_row),
row.max(anchor_row),
column.min(anchor_column),
column.max(anchor_column),
))
}
fn selection_text(&self, headers: bool, markdown: bool) -> Option<String> {
let (top, bottom, left, right) = self.selection_bounds()?;
let page = self.query_result.as_ref().map_or(&self.page, |result| &result.page);
let rows = page.rows.get(top..=bottom)?;
let mut output = String::new();
if headers {
append_record(
&mut output,
(left..=right).map(|column| {
self.query_result.as_ref().map_or_else(
|| self.columns[column].label.as_str(),
|result| result.headers[column].as_str(),
)
}),
markdown,
);
} else if markdown {
append_record(&mut output, (left..=right).map(|_| ""), true);
}
if markdown {
append_record(&mut output, (left..=right).map(|_| "---"), true);
}
for row in rows {
append_record(
&mut output,
row.get(left..=right)?.iter().map(|value| value.as_deref().unwrap_or("NULL")),
markdown,
);
}
Some(output)
}
pub(crate) fn handle_key(&mut self, key: KeyEvent) {
self.copy_status = None;
match key.code {
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
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') if self.query_result.is_none() => {
self.clear_selection();
self.show_schema = !self.show_schema;
self.column_offset = 0;
}
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 =>
{
self.load_page(self.page_index + 1);
}
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(),
_ => {}
}
}
pub(crate) fn handle_query_key(&mut self, key: KeyEvent) {
let previous_len = self.query.value.len();
let pending_g = std::mem::take(&mut self.query_g);
match key.code {
KeyCode::F(5) => self.execute_query(),
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(),
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),
'0' => Some(KeyCode::Home),
'$' => Some(KeyCode::End),
'^' | 'w' | 'b' | 'e' | 'G' => {
self.query.vim_motion(character);
None
}
'g' => {
if pending_g {
self.query.vim_motion('g');
} else {
self.query_g = true;
}
None
}
'a' => {
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);
}
fn highlight_query(&mut self, previous_len: usize) {
// Editing inserts or removes bytes; cursor movement does not invalidate syntax.
if self.query.value.len() != previous_len {
self.query_highlights = sql::highlight(&self.query.value, self.db.name() == "SQLite");
}
}
fn execute_query(&mut self) {
match self.db.query(&self.query.value) {
Ok(result) => {
self.clear_selection();
self.error = None;
if self.db.access_mode() == crate::db::AccessMode::ReadWrite {
self.refresh();
}
self.rows_state = TableState::default()
.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);
}
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 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);
}
}
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.clear_selection();
self.active = index;
self.query_result = None;
self.columns.clear();
self.page = Page {
rows: Vec::new(),
has_next: false,
};
self.page_index = 0;
self.column_offset = 0;
self.rows_state = TableState::default();
self.schema_state = TableState::default();
self.error = None;
if let Some(index) = self.active {
match self.db.columns(&self.relations[index]) {
Ok(columns) => {
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())),
}
}
}
fn load_page(&mut self, index: usize) {
if let Some(active) = self.active {
// ponytail: synchronous reads block input; use a worker if responsiveness matters.
match self.db.page(&self.relations[active], &self.columns, index) {
Ok(page) => {
self.clear_selection();
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;
}
Err(error) => self.error = Some(error_text(error.as_ref())),
}
}
}
fn refresh(&mut self) {
match self.db.relations() {
Ok(relations) => {
let id = self.active.map(|index| self.relations[index].id);
let selected = relations
.iter()
.position(|relation| Some(relation.id) == id)
.or_else(|| (!relations.is_empty()).then_some(0));
self.relations = relations;
self.open_relation(selected);
}
Err(error) => self.error = Some(error_text(error.as_ref())),
}
}
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×17. Ctrl-C to quit."),
area,
);
return;
}
let [error_area, body] = Layout::vertical([
Constraint::Length(u16::from(self.error.is_some())),
Constraint::Min(4),
])
.areas(area);
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,
Some(sql::text(&self.query.value, &self.query_highlights)),
);
let title = if let Some(headers) = self.copy_headers {
if headers { " Copy with headers · m Markdown / c CSV " }
else { " Copy without headers · m Markdown / c CSV " }
} else if self.selection_anchor.is_some() {
" VISUAL BLOCK · ym/yc with headers · Ym/Yc without "
} else if let Some(status) = &self.copy_status {
status.as_str()
} else if self.query_result.is_some() {
" Results "
} else if self.show_schema {
" Schema · s for rows "
} else {
" Rows · s for schema "
};
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.rows_state.selected_column()
};
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)
} else {
Constraint::Length(20)
}
});
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,
result.affected_rows.map_or_else(String::new, |rows| format!(" · {rows} rows affected")),
)
} else if let Some(index) = self.active {
let relation = &self.relations[index];
if self.show_schema {
format!(
" [{}] · {} columns · fields {}–{}/5",
relation.kind,
self.columns.len(),
self.column_offset + 1,
self.column_offset + visible
)
} else {
let start = if self.page.rows.is_empty() {
0
} else {
self.page_index * PAGE_SIZE + 1
};
let end = self.page_index * PAGE_SIZE + self.page.rows.len();
format!(
" [{}] · rows {start}–{end} · page {}{} · columns {}–{}/{}",
relation.kind,
self.page_index + 1,
if self.page.has_next { " (more)" } else { "" },
if count > 0 { self.column_offset + 1 } else { 0 },
self.column_offset + visible,
count
)
}
} else {
" No readable relations. r to refresh.".into()
};
let mut content_block =
panel(title, focus == Focus::Rows).title_bottom(Line::from(description).left_aligned());
if self.query_result.is_none()
&& let Some(index) = self.active
{
content_block = content_block.title_top(
Line::from(format!(" {} ", self.relations[index].label)).right_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,
);
} else if self.show_schema {
let headers = ["Column", "Type", "Nullable", "Key", "Default"];
let rows = self.columns.iter().map(|column| {
Row::new(
[
column.label.as_str(),
column.data_type.as_str(),
if column.nullable { "yes" } else { "no" },
if column.primary_key { "PK" } else { "" },
column.default.as_str(),
]
.into_iter()
.skip(self.column_offset)
.take(visible),
)
});
let table = Table::new(rows, widths)
.header(
Row::new(headers.into_iter().skip(self.column_offset).take(visible))
.style(header_style()),
)
.block(content_block)
.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() {
"Could not load rows. See error above."
} else {
"No rows."
})
.block(content_block),
content,
);
} else {
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 bounds = self.selection_anchor.and_then(|_| self.selection_bounds());
let rows = page.rows.iter().enumerate().map(|(row_index, row)| {
Row::new(
row.iter()
.enumerate()
.skip(self.column_offset)
.take(visible)
.map(|(column_index, value)| {
let cell = match value {
Some(value) => Cell::from(value.as_str()),
None => Cell::from("NULL").style(
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
),
};
if bounds.is_some_and(|(top, bottom, left, right)| {
(top..=bottom).contains(&row_index)
&& (left..=right).contains(&column_index)
}) {
cell.style(selected_style())
} else {
cell
}
}),
)
});
let table = Table::new(rows, widths)
.header(Row::new(headers).style(header_style()))
.block(content_block)
.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(
Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)),
error_area,
);
}
}
}
fn append_record<'a>(
output: &mut String,
values: impl Iterator<Item = &'a str>,
markdown: bool,
) {
if markdown {
output.push('|');
}
for (index, value) in values.enumerate() {
if markdown {
output.push(' ');
for character in value.chars() {
match character {
'&' => output.push_str("&amp;"),
'<' => output.push_str("&lt;"),
'>' => output.push_str("&gt;"),
'\\' | '|' | '`' | '*' | '_' | '[' | ']' => {
output.push('\\');
output.push(character);
}
_ => output.push(character),
}
}
output.push_str(" |");
} else {
if index > 0 {
output.push(',');
}
if !value.contains([',', '"', '\r', '\n']) {
output.push_str(value);
continue;
}
output.push('"');
for character in value.chars() {
if character == '"' {
output.push('"');
}
output.push(character);
}
output.push('"');
}
}
output.push_str(if markdown { "\n" } else { "\r\n" });
}
fn copy_to_clipboard(text: &str) -> Result<()> {
use std::{io::Write, process::{Command, Stdio}};
let (program, args): (&str, &[&str]) = if cfg!(target_os = "macos") {
("pbcopy", &[])
} else if cfg!(target_os = "windows") {
("powershell.exe", &["-NoProfile", "-Command",
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new(); Set-Clipboard -Value ([Console]::In.ReadToEnd())"])
} else if std::env::var_os("WAYLAND_DISPLAY").is_some() {
("wl-copy", &[])
} else {
("xclip", &["-selection", "clipboard"])
};
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|error| format!("could not start {program}: {error}"))?;
let written = child.stdin.take().unwrap().write_all(text.as_bytes());
let status = child.wait()?;
if !status.success() {
return Err(format!("{program} failed ({status})").into());
}
written?;
Ok(())
}
fn panel(title: &str, focused: bool) -> Block<'_> {
Block::bordered()
.title(title)
.border_style(Style::default().fg(if focused {
Color::Cyan
} else {
Color::DarkGray
}))
}
fn selected_style() -> Style {
Style::default().bg(Color::Blue).fg(Color::White)
}
fn header_style() -> Style {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
}
fn moved_index(selected: Option<usize>, count: usize, delta: isize) -> Option<usize> {
if count == 0 {
return None;
}
Some(
selected
.unwrap_or(0)
.saturating_add_signed(delta)
.min(count - 1),
)
}
#[cfg(test)]
mod tests {
use super::moved_index;
#[test]
fn csv_quotes_only_fields_with_delimiters_quotes_or_line_breaks() {
let mut output = String::new();
super::append_record(
&mut output,
["42", "00123", "界", "", "a,b", "a\"b", "a\rb", "a\nb"].into_iter(),
false,
);
assert_eq!(output, "42,00123,界,,\"a,b\",\"a\"\"b\",\"a\rb\",\"a\nb\"\r\n");
}
#[test]
fn rectangular_selection_copies_full_cells_and_resets_on_data_changes() -> crate::Result<()> {
use super::{App, Focus};
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);
INSERT INTO cells VALUES
('outside', 'a|b', 'say \"hi\",界', 'outside'),
('outside', NULL, '<&>\\tail', 'outside'),
('outside', 'outside', 'outside', 'outside');
WITH RECURSIVE n(x) AS (VALUES(4) UNION ALL SELECT x+1 FROM n WHERE x<101)
INSERT INTO cells SELECT x,x,x,x FROM n;",
)?;
let mut app = App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?;
let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
// Anchor at the bottom right, extend up and left across a scrolled viewport.
for character in ['j', 'l', 'l'] {
app.handle_key(key(character));
}
let mut terminal = Terminal::new(TestBackend::new(60, 20))?;
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Rows))?;
assert!(app.handle_rows_command(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)));
app.handle_key(key('k'));
app.handle_key(key('h'));
terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Rows))?;
let buffer = terminal.backend().buffer();
assert_eq!(app.column_offset, 1);
for position in [(1, 12), (22, 12), (1, 13), (22, 13)] {
assert_eq!(buffer[position].bg, Color::Blue);
}
assert_ne!(buffer[(1, 14)].bg, Color::Blue);
assert_eq!(
app.selection_text(true, true).as_deref(),
Some("| b | c |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | &lt;&amp;&gt;\\\\tail |\n")
);
assert_eq!(
app.selection_text(false, true).as_deref(),
Some("| | |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | &lt;&amp;&gt;\\\\tail |\n")
);
assert_eq!(
app.selection_text(true, false).as_deref(),
Some("b,c\r\na|b,\"say \"\"hi\"\",界\"\r\nNULL,<&>\\tail\r\n")
);
assert_eq!(
app.selection_text(false, false).as_deref(),
Some("a|b,\"say \"\"hi\"\",界\"\r\nNULL,<&>\\tail\r\n")
);
assert!(app.handle_rows_command(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
assert_eq!(app.selection_text(false, false).as_deref(), Some("a|b\r\n"));
// A prefix cancels on focus changes rather than capturing a later key.
app.handle_rows_command(key('y'));
assert!(!app.handle_rows_command(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
assert!(app.copy_headers.is_none());
app.handle_rows_command(key('v'));
app.handle_key(key('n'));
assert!(app.selection_anchor.is_none());
assert_eq!(app.selection_text(false, false).as_deref(), Some("101\r\n"));
app.handle_rows_command(key('v'));
app.handle_key(key('s'));
assert!(app.selection_anchor.is_none());
assert!(app.selection_text(true, true).is_none());
app.handle_key(key('s'));
app.handle_rows_command(key('v'));
app.query.value = "SELECT 42 AS answer".into();
app.execute_query();
assert!(app.selection_anchor.is_none());
assert_eq!(app.selection_text(true, false).as_deref(), Some("answer\r\n42\r\n"));
app.handle_rows_command(key('v'));
app.query.value = "SELECT missing FROM nowhere".into();
app.execute_query();
assert_eq!(app.selection_text(false, false).as_deref(), Some("42\r\n"));
assert!(app.selection_anchor.is_some());
app.open_relation(Some(0));
assert!(app.selection_anchor.is_none());
app.query.value = "SELECT * FROM cells WHERE 0".into();
app.execute_query();
app.handle_rows_command(key('v'));
assert!(app.selection_text(true, true).is_none());
Ok(())
}
#[test]
fn navigation_clamps_to_available_items() {
assert_eq!(moved_index(None, 0, 1), None);
assert_eq!(moved_index(None, 3, 1), Some(1));
assert_eq!(moved_index(Some(0), 3, -1), Some(0));
assert_eq!(moved_index(Some(2), 3, 1), Some(2));
assert_eq!(moved_index(Some(1), 3, isize::MIN), Some(0));
assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2));
}
#[test]
fn vim_query_motions_preserve_sql_and_handle_unicode() -> crate::Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let file = tempfile::NamedTempFile::new()?;
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?;
let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
app.handle_query_key(key('i'));
app.paste_query(" SELECT café,界\n\n FROM data");
app.leave_query_insert();
for (motion, expected) in [
('g', (3, 12)),
('g', (1, 1)),
('^', (1, 3)),
('e', (1, 8)),
('w', (1, 10)),
('e', (1, 13)),
('w', (1, 14)),
('w', (1, 15)),
('w', (3, 3)),
('b', (1, 15)),
('b', (1, 14)),
('b', (1, 10)),
('0', (1, 1)),
('$', (1, 16)),
('G', (3, 3)),
('e', (3, 6)),
('w', (3, 8)),
('w', (3, 12)),
('w', (3, 12)),
] {
app.handle_query_key(key(motion));
assert_eq!(app.query_position(), expected, "motion {motion}");
assert!(!app.query_insert_mode());
}
assert_eq!(app.query.value, " SELECT café,界\n\n FROM data");
app.handle_query_key(key('i'));
for character in ['w', 'b', 'e', '0', '^', '$', 'g', 'G'] {
app.handle_query_key(key(character));
}
assert_eq!(app.query.value, " SELECT café,界\n\n FROM datawbe0^$gG");
app.leave_query_insert();
app.handle_query_key(key('g'));
app.handle_query_key(key('0'));
app.handle_query_key(key('g'));
assert_eq!(app.query_position(), (3, 1));
Ok(())
}
#[test]
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(), Default::default())?)?;
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(), Default::default())?)?;
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(), Default::default())?)?;
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};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{Terminal, backend::TestBackend, style::Color};
let file = tempfile::NamedTempFile::new()?;
let mut app = App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?;
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[(3, 1)].fg, Color::Cyan);
assert_eq!(buffer[(10, 1)].fg, Color::Green);
assert_eq!(buffer[(3, 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()[(3, 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))?;
let cursor = terminal.get_cursor_position()?;
let buffer = terminal.backend().buffer();
assert_eq!(cursor.x, 58);
assert_eq!(buffer[(cursor.x - 1, cursor.y)].symbol(), "x");
assert_eq!(buffer[(4, cursor.y)].fg, Color::DarkGray);
Ok(())
}
#[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(), Default::default())?)?;
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));
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()?;
rusqlite::Connection::open(file.path())?.execute_batch(
"CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (1);",
)?;
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?;
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 20))?;
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(), super::Focus::Rows))?;
let buffer = terminal.backend().buffer();
let border: String = (0..100).map(|x| buffer[(x, 19)].symbol()).collect();
assert!(border.ends_with('┘'), "{border}");
assert!(!border.contains("main.comments"));
assert!((0..19).any(|y| {
let line: String = (0..100).map(|x| buffer[(x, y)].symbol()).collect();
line.ends_with(" main.comments ┐")
}));
assert!(border.contains(if schema {
"fields 1–4/5"
} else {
"rows 1–1"
}));
if let Some(error) = error {
let message: String = (0..100).map(|x| buffer[(x, 0)].symbol()).collect();
assert!(message.starts_with(error), "{message}");
}
}
Ok(())
}
#[test]
fn writes_refresh_browsing_and_keep_returning_results() -> crate::Result<()> {
let file = tempfile::NamedTempFile::new()?;
let mut app = super::App::new(crate::db::Database::open_sqlite(
file.path(),
crate::db::AccessMode::ReadWrite,
)?)?;
app.query.value = "CREATE TABLE edited (id INTEGER PRIMARY KEY)".into();
app.execute_query();
assert!(app.error.is_none(), "{:?}", app.error);
assert_eq!(app.relations[0].name, "edited");
app.query.value = "INSERT INTO edited VALUES (42) RETURNING id".into();
app.execute_query();
assert!(app.error.is_none(), "{:?}", app.error);
let result = app.query_result.as_ref().unwrap();
assert_eq!(result.affected_rows, Some(1));
assert_eq!(result.page.rows, [vec![Some("42".into())]]);
app.open_relation(Some(0));
assert_eq!(app.page.rows, [vec![Some("42".into())]]);
app.query.value = "DROP TABLE edited".into();
app.execute_query();
assert!(app.error.is_none(), "{:?}", app.error);
assert!(app.relations.is_empty());
Ok(())
}
}