Files
dbdash/src/app.rs
T
2026-10-19 14:08:37 +02:00

495 lines
18 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,
};
#[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,
schema_state: TableState,
column_offset: usize,
show_schema: bool,
query: Input,
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(),
schema_state: TableState::default(),
column_offset: 0,
show_schema: false,
query: Input::default(),
query_result: None,
error: None,
};
app.open_relation(selected);
Ok(app)
}
pub(crate) fn handle_key(&mut self, key: KeyEvent) {
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.show_schema = !self.show_schema;
self.column_offset = 0;
}
KeyCode::Left | KeyCode::Char('h') => {
self.column_offset = self.column_offset.saturating_sub(1);
}
KeyCode::Right | KeyCode::Char('l') => {
let count = if self.show_schema {
5
} else {
self.query_result
.as_ref()
.map_or(self.columns.len(), |result| result.headers.len())
};
if self.column_offset + 1 < count {
self.column_offset += 1;
}
}
KeyCode::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) {
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 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(),
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.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.rows_state =
TableState::default().with_selected((!page.rows.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×14. Ctrl-C to quit."),
area,
);
return;
}
let [header, error_area, body] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(u16::from(self.error.is_some())),
Constraint::Min(4),
])
.areas(area);
frame.render_widget(
Paragraph::new(Line::from_iter([
" dbdash │ ",
self.db.name(),
" │ READ ONLY",
]))
.style(Style::default().fg(Color::Cyan)),
header,
);
let [query, content] =
Layout::vertical([Constraint::Ratio(1, 2), Constraint::Min(4)]).areas(body);
self.query.draw(
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 "
};
let count = if self.show_schema {
5
} else {
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)
.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,
)
} else if let Some(index) = self.active {
let relation = &self.relations[index];
if self.show_schema {
format!(
" {} [{}] · {} columns · fields {}–{}/5",
relation.label,
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.label,
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 content_block =
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,
);
} 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)
.row_highlight_style(selected_style())
.highlight_symbol("› ");
frame.render_stateful_widget(table, content, &mut self.schema_state);
} 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 rows = page.rows.iter().map(|row| {
Row::new(
row.iter()
.skip(self.column_offset)
.take(visible)
.map(|value| match value {
Some(value) => Cell::from(value.as_str()),
None => Cell::from("NULL").style(
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
),
}),
)
});
let table = Table::new(rows, widths)
.header(Row::new(headers).style(header_style()))
.block(content_block)
.row_highlight_style(selected_style())
.highlight_symbol("› ");
frame.render_stateful_widget(table, content, &mut self.rows_state);
}
if let Some(error) = &self.error {
frame.render_widget(
Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)),
error_area,
);
}
}
}
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 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 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()?;
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())?)?;
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.starts_with("└ main.comments [table]"), "{border}");
assert!(border.ends_with('┘'), "{border}");
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, 1)].symbol()).collect();
assert!(message.starts_with(error), "{message}");
}
}
Ok(())
}
}