feat: add read-only PostgreSQL inspection TUI
This commit is contained in:
+408
@@ -0,0 +1,408 @@
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use ratatui::{
|
||||
DefaultTerminal, Frame,
|
||||
layout::{Constraint, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
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 fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
|
||||
loop {
|
||||
terminal.draw(|frame| self.draw(frame))?;
|
||||
if let Event::Key(key) = event::read()?
|
||||
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
&& self.handle_key(key)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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; server timeout is 5s, 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 oid = self.active.map(|index| self.relations[index].oid);
|
||||
let selected = relations
|
||||
.iter()
|
||||
.position(|relation| Some(relation.oid) == oid)
|
||||
.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())),
|
||||
}
|
||||
}
|
||||
|
||||
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(" dbdash │ PostgreSQL │ 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 · q/Ctrl-C 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user