use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState},
Frame,
};
use sqlx::PgPool;
use crate::tui::app::{copy_to_clipboard, AppAction};
struct TableSummary {
name: String,
row_count: i64,
}
pub struct SchemaVisualizerTab {
tables: Vec<TableSummary>,
table_list_state: ListState,
filter: String,
filter_active: bool,
columns: Vec<rok_orm::ColumnInfo>,
foreign_keys: Vec<rok_orm::ForeignKeyInfo>,
indexes: Vec<rok_orm::IndexInfo>,
col_table_state: TableState,
status: String,
}
impl Default for SchemaVisualizerTab {
fn default() -> Self {
let mut table_list_state = ListState::default();
table_list_state.select(Some(0));
Self {
tables: Vec::new(),
table_list_state,
filter: String::new(),
filter_active: false,
columns: Vec::new(),
foreign_keys: Vec::new(),
indexes: Vec::new(),
col_table_state: TableState::default(),
status: "Loading...".into(),
}
}
}
impl SchemaVisualizerTab {
pub async fn load(&mut self, pool: &PgPool) {
let tables = fetch_table_list(pool).await;
self.tables = match tables {
Ok(t) => {
self.status = format!("{} tables", t.len());
t
}
Err(e) => {
self.status = format!("Error: {e}");
Vec::new()
}
};
let selected = self.table_list_state.selected().unwrap_or(0);
if !self.tables.is_empty() && selected >= self.tables.len() {
self.table_list_state.select(Some(self.tables.len().saturating_sub(1)));
}
self.load_selected_table(pool).await;
}
async fn load_selected_table(&mut self, pool: &PgPool) {
let Some(idx) = self.table_list_state.selected() else { return };
let tables = self.filtered_tables();
let Some(table) = tables.get(idx) else { return };
let name = &table.name;
let cols = rok_orm::SchemaInspector::columns(name, pool).await;
let fks = rok_orm::SchemaInspector::foreign_keys(name, pool).await;
let idxs = rok_orm::SchemaInspector::indexes(name, pool).await;
self.columns = cols.unwrap_or_default();
self.foreign_keys = fks.unwrap_or_default();
self.indexes = idxs.unwrap_or_default();
}
fn filtered_tables(&self) -> Vec<&TableSummary> {
if self.filter.is_empty() {
self.tables.iter().collect()
} else {
let f = self.filter.to_lowercase();
self.tables
.iter()
.filter(|t| t.name.to_lowercase().contains(&f))
.collect()
}
}
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
.split(area);
self.render_table_list(frame, chunks[0]);
self.render_table_detail(frame, chunks[1]);
}
fn render_table_list(&mut self, frame: &mut Frame, area: Rect) {
let filtered = self.filtered_tables();
let max_rows = filtered.iter().map(|t| t.row_count).max().unwrap_or(1).max(1);
let items: Vec<ListItem> = filtered
.iter()
.map(|t| {
let bar_len = ((t.row_count as f64 / max_rows as f64) * 10.0) as usize;
let bar = "â–‰".repeat(bar_len);
let count_str = if t.row_count > 1_000_000 {
format!("{:.1}M", t.row_count as f64 / 1_000_000.0)
} else if t.row_count > 1_000 {
format!("{:.1}K", t.row_count as f64 / 1_000.0)
} else {
t.row_count.to_string()
};
let label = format!(" {} {:>8} {}", bar, count_str, t.name);
ListItem::new(Line::from(Span::raw(label)))
})
.collect();
let title = if self.filter_active {
format!(" Tables — filter: {} ", self.filter)
} else {
format!(" Tables — {} ", self.status)
};
let list = List::new(items)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol("â–¶ ");
frame.render_stateful_widget(list, area, &mut self.table_list_state);
}
fn render_table_detail(&mut self, frame: &mut Frame, area: Rect) {
let tables = self.filtered_tables();
let selected = self
.table_list_state
.selected()
.and_then(|i| tables.get(i).copied());
let Some(table) = selected else {
let para = Paragraph::new(" Select a table from the left pane ")
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().title(" Detail ").borders(Borders::ALL));
frame.render_widget(para, area);
return;
};
let header = format!(" Table: {} ", table.name);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5),
Constraint::Length(5.min(area.height / 4)),
Constraint::Length(4.min(area.height / 4)),
])
.split(area);
self.render_columns(frame, chunks[0], &header);
self.render_relationships(frame, chunks[1]);
self.render_indexes(frame, chunks[2]);
}
fn render_columns(&mut self, frame: &mut Frame, area: Rect, header: &str) {
let col_header = Row::new(vec![
Cell::from("Column").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Type").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Nullable").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Default").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Key").style(Style::default().add_modifier(Modifier::BOLD)),
])
.height(1);
let rows: Vec<Row> = self
.columns
.iter()
.map(|c| {
let nullable_str = if c.nullable { "YES" } else { "NO" };
let key_str = if c.is_pk { "PK" } else { "" };
Row::new(vec![
Cell::from(c.name.as_str()).style(Style::default().fg(Color::White)),
Cell::from(c.data_type.as_str()).style(Style::default().fg(Color::Yellow)),
Cell::from(nullable_str).style(Style::default().fg(if c.nullable {
Color::Green
} else {
Color::Red
})),
Cell::from(
c.default
.as_deref()
.unwrap_or("")
)
.style(Style::default().fg(Color::DarkGray)),
Cell::from(key_str)
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
])
})
.collect();
let widths = [
Constraint::Length(18),
Constraint::Length(24),
Constraint::Length(10),
Constraint::Length(28),
Constraint::Length(5),
];
let total = self.columns.len();
let table = Table::new(rows, widths)
.header(col_header)
.block(
Block::default()
.title(format!("{header} Columns ({total}) "))
.borders(Borders::ALL),
)
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol("â–¸ ");
frame.render_stateful_widget(table, area, &mut self.col_table_state);
}
fn render_relationships(&mut self, frame: &mut Frame, area: Rect) {
if self.foreign_keys.is_empty() {
let para = Paragraph::new(" No foreign key relationships ")
.style(Style::default().fg(Color::DarkGray))
.block(
Block::default()
.title(" Relationships ")
.borders(Borders::ALL),
);
frame.render_widget(para, area);
return;
}
let rows: Vec<Row> = self
.foreign_keys
.iter()
.map(|fk| {
let local_cols = fk.columns.join(", ");
let fk_cols = fk.foreign_columns.join(", ");
Row::new(vec![
Cell::from(local_cols).style(Style::default().fg(Color::Cyan)),
Cell::from("→").style(Style::default().fg(Color::DarkGray)),
Cell::from(fk.foreign_table.as_str())
.style(Style::default().fg(Color::Green)),
Cell::from(format!("({fk_cols})"))
.style(Style::default().fg(Color::DarkGray)),
])
})
.collect();
let widths = [
Constraint::Length(18),
Constraint::Length(3),
Constraint::Length(18),
Constraint::Min(10),
];
let table = Table::new(rows, widths).block(
Block::default()
.title(format!(" Relationships ({}) ", self.foreign_keys.len()))
.borders(Borders::ALL),
);
frame.render_widget(table, area);
}
fn render_indexes(&mut self, frame: &mut Frame, area: Rect) {
if self.indexes.is_empty() {
let para = Paragraph::new(" No indexes ")
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().title(" Indexes ").borders(Borders::ALL));
frame.render_widget(para, area);
return;
}
let rows: Vec<Row> = self
.indexes
.iter()
.map(|idx| {
let kind = if idx.primary {
"PK"
} else if idx.unique {
"UNIQUE"
} else {
"BTREE"
};
let cols = idx.columns.join(", ");
Row::new(vec![
Cell::from(idx.name.as_str()).style(Style::default().fg(Color::Magenta)),
Cell::from(kind).style(Style::default().fg(if idx.primary {
Color::Cyan
} else if idx.unique {
Color::Yellow
} else {
Color::DarkGray
})),
Cell::from(cols).style(Style::default().fg(Color::White)),
])
})
.collect();
let widths = [
Constraint::Length(24),
Constraint::Length(8),
Constraint::Min(10),
];
let table = Table::new(rows, widths).block(
Block::default()
.title(format!(" Indexes ({}) ", self.indexes.len()))
.borders(Borders::ALL),
);
frame.render_widget(table, area);
}
pub fn handle_key(&mut self, key: KeyEvent) -> AppAction {
if self.filter_active {
match key.code {
KeyCode::Esc => {
self.filter_active = false;
self.filter.clear();
AppAction::Reload
}
KeyCode::Char(c) => {
self.filter.push(c);
self.table_list_state.select(Some(0));
AppAction::None
}
KeyCode::Backspace => {
self.filter.pop();
self.table_list_state.select(Some(0));
AppAction::None
}
KeyCode::Enter => {
self.filter_active = false;
AppAction::Reload
}
_ => AppAction::None,
}
} else {
match key.code {
KeyCode::Up => {
let tables = self.filtered_tables();
let max = tables.len().saturating_sub(1);
let i = self.table_list_state.selected().unwrap_or(0).saturating_sub(1).min(max);
self.table_list_state.select(Some(i));
AppAction::None
}
KeyCode::Down => {
let tables = self.filtered_tables();
let max = tables.len().saturating_sub(1);
let i = (self.table_list_state.selected().unwrap_or(0) + 1).min(max);
self.table_list_state.select(Some(i));
AppAction::None
}
KeyCode::Char('/') => {
self.filter_active = true;
self.filter.clear();
AppAction::None
}
KeyCode::Char('c') | KeyCode::Char('C') => {
if let Some(idx) = self.table_list_state.selected() {
let tables = self.filtered_tables();
if let Some(t) = tables.get(idx) {
copy_to_clipboard(&t.name);
}
}
AppAction::None
}
KeyCode::F(5) | KeyCode::Char('r') | KeyCode::Char('R') => AppAction::Reload,
_ => AppAction::None,
}
}
}
}
async fn fetch_table_list(pool: &PgPool) -> anyhow::Result<Vec<TableSummary>> {
use sqlx::Row as _;
let rows = sqlx::query(
"SELECT t.table_name, \
(SELECT reltuples::BIGINT FROM pg_class WHERE oid = (quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))::regclass) AS row_estimate \
FROM information_schema.tables t \
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' \
ORDER BY t.table_name",
)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| TableSummary {
name: r.try_get::<String, _>("table_name").unwrap_or_default(),
row_count: r.try_get::<i64, _>("row_estimate").unwrap_or(0),
})
.collect())
}