Files
dbdash/src/app.rs
T
2026-10-19 12:54:18 +02:00

402 lines
14 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::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::{
Frame,
layout::{Constraint, Layout},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState},
};
use crate::{
Result,
db::{Column, Database, PAGE_SIZE, Page, Relation},
error_text,
};
pub struct App {
db: Database,
relations: Vec<Relation>,
selection: ListState,
active: Option<usize>,
columns: Vec<Column>,
page: Page,
page_index: usize,
rows_state: TableState,
schema_state: TableState,
column_offset: usize,
sidebar_focus: bool,
show_schema: bool,
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,
selection: ListState::default().with_selected(selected),
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,
sidebar_focus: true,
show_schema: false,
error: None,
};
app.open_selection();
Ok(app)
}
pub(crate) fn handle_key(&mut self, key: KeyEvent) -> bool {
if key.code == KeyCode::Char('q')
|| (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))
{
return true;
}
match key.code {
KeyCode::Tab | KeyCode::BackTab => self.sidebar_focus = !self.sidebar_focus,
KeyCode::Enter if self.sidebar_focus => {
self.open_selection();
self.sidebar_focus = false;
}
KeyCode::Esc => self.sidebar_focus = true,
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') => {
self.show_schema = !self.show_schema;
self.column_offset = 0;
}
KeyCode::Left | KeyCode::Char('h') if !self.sidebar_focus => {
self.column_offset = self.column_offset.saturating_sub(1);
}
KeyCode::Right | KeyCode::Char('l') if !self.sidebar_focus => {
let count = if self.show_schema {
5
} else {
self.columns.len()
};
if self.column_offset + 1 < count {
self.column_offset += 1;
}
}
KeyCode::Char('n') | KeyCode::PageDown if !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 => {
self.load_page(self.page_index - 1);
}
KeyCode::Char('r') => self.refresh(),
_ => {}
}
false
}
fn move_selection(&mut self, delta: isize) {
if self.sidebar_focus {
let next = moved_index(self.selection.selected(), self.relations.len(), delta);
self.selection.select(next);
} else 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);
self.rows_state.select(next);
}
}
fn open_selection(&mut self) {
self.active = self.selection.selected();
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.selection = ListState::default().with_selected(selected);
self.open_selection();
}
Err(error) => self.error = Some(error_text(error.as_ref())),
}
}
pub(crate) fn draw(&mut self, frame: &mut Frame) {
let area = frame.area();
if area.width < 70 || area.height < 12 {
frame.render_widget(
Paragraph::new("Enlarge terminal to at least 70×12. q to quit."),
area,
);
return;
}
let [header, body, status, help] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(4),
Constraint::Length(2),
Constraint::Length(2),
])
.areas(area);
frame.render_widget(
Paragraph::new(Line::from_iter([
" dbdash │ ",
self.db.name(),
" │ READ ONLY",
]))
.style(Style::default().fg(Color::Cyan)),
header,
);
let [sidebar, content] =
Layout::horizontal([Constraint::Length(28), Constraint::Min(1)]).areas(body);
let sidebar_title = format!(" Relations ({}) ", self.relations.len());
let sidebar_block = panel(&sidebar_title, self.sidebar_focus);
if self.relations.is_empty() {
frame.render_widget(
Paragraph::new("No readable tables or views.\nr to refresh.").block(sidebar_block),
sidebar,
);
} else {
let list = List::new(
self.relations
.iter()
.map(|relation| ListItem::new(relation.label.as_str())),
)
.block(sidebar_block)
.highlight_style(selected_style())
.highlight_symbol("› ");
frame.render_stateful_widget(list, sidebar, &mut self.selection);
}
let title = if self.show_schema {
" Schema · s for rows "
} else {
" Rows · s for schema "
};
let content_block = panel(title, !self.sidebar_focus);
let count = if self.show_schema {
5
} else {
self.columns.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)
}
});
if self.active.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 self.page.rows.is_empty() {
frame.render_widget(
Paragraph::new(if self.error.is_some() {
"Could not load rows. See error below."
} else {
"No rows."
})
.block(content_block),
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| {
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);
}
let description = 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 [description_area, error_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(status);
frame.render_widget(Paragraph::new(description), description_area);
if let Some(error) = &self.error {
frame.render_widget(
Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)),
error_area,
);
}
frame.render_widget(Paragraph::new(
" Tab focus · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · c connect · q quit"
).style(Style::default().fg(Color::DarkGray)), help);
}
}
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));
}
}