use crate::config::Config;
use crate::status_metrics::{HealthIndicator, StatusMetrics, METRICS_REFRESH_INTERVAL};
use anyhow::Result;
use cqlite_core::Database;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{
Block, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState, Wrap,
},
Frame, Terminal,
};
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
pub async fn start_tui_mode(db_path: &Path, config: &Config, database: Database) -> Result<()> {
log::set_max_level(log::LevelFilter::Off);
let db = Arc::new(database);
enable_raw_mode()?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = TuiApp::new(db_path, config, db).await?;
let res = run_tui(&mut terminal, &mut app).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
struct PanelVisibility {
tables: bool, results: bool, history: bool, }
impl Default for PanelVisibility {
fn default() -> Self {
Self {
tables: true,
results: true,
history: true,
}
}
}
impl PanelVisibility {
fn reset(&mut self) {
*self = Self::default();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FocusPanel {
Tables,
Results,
History,
Input,
}
impl FocusPanel {
fn next(self, visibility: &PanelVisibility) -> Self {
let order = [
(FocusPanel::Tables, visibility.tables),
(FocusPanel::Results, visibility.results),
(FocusPanel::History, visibility.history),
(FocusPanel::Input, true), ];
let current_idx = order.iter().position(|(p, _)| *p == self).unwrap_or(3);
for i in 1..=order.len() {
let next_idx = (current_idx + i) % order.len();
if order[next_idx].1 {
return order[next_idx].0;
}
}
FocusPanel::Input }
fn prev(self, visibility: &PanelVisibility) -> Self {
let order = [
(FocusPanel::Tables, visibility.tables),
(FocusPanel::Results, visibility.results),
(FocusPanel::History, visibility.history),
(FocusPanel::Input, true),
];
let current_idx = order.iter().position(|(p, _)| *p == self).unwrap_or(3);
for i in 1..=order.len() {
let prev_idx = (current_idx + order.len() - i) % order.len();
if order[prev_idx].1 {
return order[prev_idx].0;
}
}
FocusPanel::Input
}
}
#[derive(Debug, Clone)]
struct TableEntry {
#[allow(dead_code)] keyspace: String,
#[allow(dead_code)] name: String,
qualified_name: String, }
#[derive(Debug)]
struct TablesBrowserState {
entries: Vec<TableEntry>,
filtered_indices: Vec<usize>, filter_text: String,
filter_active: bool, list_state: ListState,
}
impl Default for TablesBrowserState {
fn default() -> Self {
Self {
entries: Vec::new(),
filtered_indices: Vec::new(),
filter_text: String::new(),
filter_active: false,
list_state: ListState::default(),
}
}
}
impl TablesBrowserState {
fn apply_filter(&mut self) {
if self.filter_text.is_empty() {
self.filtered_indices = (0..self.entries.len()).collect();
} else {
let filter_lower = self.filter_text.to_lowercase();
self.filtered_indices = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| e.qualified_name.to_lowercase().contains(&filter_lower))
.map(|(i, _)| i)
.collect();
}
if let Some(selected) = self.list_state.selected() {
if selected >= self.filtered_indices.len() {
if self.filtered_indices.is_empty() {
self.list_state.select(None);
} else {
self.list_state.select(Some(0));
}
}
}
}
fn selected_entry(&self) -> Option<&TableEntry> {
self.list_state
.selected()
.and_then(|idx| self.filtered_indices.get(idx))
.and_then(|&entry_idx| self.entries.get(entry_idx))
}
}
#[derive(Debug)]
struct ResultsTableState {
columns: Vec<String>,
rows: Vec<Vec<String>>,
row_offset: usize, col_offset: usize, selected_row: Option<usize>,
column_widths: Vec<u16>, table_state: TableState,
}
impl Default for ResultsTableState {
fn default() -> Self {
Self {
columns: Vec::new(),
rows: Vec::new(),
row_offset: 0,
col_offset: 0,
selected_row: None,
column_widths: Vec::new(),
table_state: TableState::default(),
}
}
}
impl ResultsTableState {
fn calculate_widths(&mut self) {
if self.columns.is_empty() {
self.column_widths = vec![];
return;
}
let mut widths: Vec<u16> = self.columns.iter().map(|c| c.len() as u16).collect();
for row in self.rows.iter().take(100) {
for (i, cell) in row.iter().enumerate() {
if i < widths.len() {
widths[i] = widths[i].max(cell.len() as u16);
}
}
}
for w in &mut widths {
*w = (*w + 2).min(40);
}
self.column_widths = widths;
}
fn visible_columns(&self, available_width: u16) -> std::ops::Range<usize> {
if self.column_widths.is_empty() {
return 0..0;
}
let start = self
.col_offset
.min(self.column_widths.len().saturating_sub(1));
let mut end = start;
let mut used_width = 0u16;
for i in start..self.column_widths.len() {
let col_width = self.column_widths.get(i).copied().unwrap_or(10);
if used_width + col_width > available_width && end > start {
break;
}
used_width += col_width;
end = i + 1;
}
start..end.max(start + 1).min(self.columns.len())
}
fn has_scroll_left(&self) -> bool {
self.col_offset > 0
}
fn has_scroll_right(&self, available_width: u16) -> bool {
let visible = self.visible_columns(available_width);
visible.end < self.columns.len()
}
fn clear(&mut self) {
self.columns.clear();
self.rows.clear();
self.row_offset = 0;
self.col_offset = 0;
self.selected_row = None;
self.column_widths.clear();
self.table_state = TableState::default();
}
}
struct LayoutAreas {
header: Rect,
tables: Option<Rect>,
results: Option<Rect>,
history: Option<Rect>,
input: Rect,
status: Rect,
}
struct TuiApp {
db_path: std::path::PathBuf,
database: Arc<Database>,
input: String,
#[allow(dead_code)] input_mode: InputMode,
messages: Vec<String>,
#[allow(dead_code)] scroll_offset: usize,
history: Vec<String>,
history_index: Option<usize>,
query_results: Vec<QueryDisplayResult>,
#[allow(dead_code)] results_scroll: ListState,
show_help: bool,
status_message: String,
#[allow(dead_code)] last_execution_time: Option<Duration>,
status_metrics: Option<StatusMetrics>,
metrics_last_updated: Option<Instant>,
panel_visibility: PanelVisibility,
focus_panel: FocusPanel,
tables_browser: TablesBrowserState,
results_table: ResultsTableState,
history_scroll: ListState,
current_keyspace: Option<String>,
}
#[derive(Clone, PartialEq)]
#[allow(dead_code)] enum InputMode {
Normal,
Editing,
Results,
Help,
}
#[derive(Clone)]
struct QueryDisplayResult {
query: String,
success: bool,
#[allow(dead_code)] rows: usize,
execution_time: Option<Duration>,
#[allow(dead_code)] error_message: Option<String>,
}
impl TuiApp {
async fn new(db_path: &Path, config: &Config, database: Arc<Database>) -> Result<Self> {
let initial_metrics = StatusMetrics::collect(Some(db_path), Some(&database)).await;
let mut app = TuiApp {
db_path: db_path.to_path_buf(),
database,
input: String::new(),
input_mode: InputMode::Editing,
messages: vec![
"Welcome to CQLite TUI Mode!".to_string(),
"Type CQL queries and press Enter to execute.".to_string(),
"Press F1 for help, Tab to navigate panels, Esc to exit.".to_string(),
String::new(),
],
scroll_offset: 0,
history: Vec::new(),
history_index: None,
query_results: Vec::new(),
results_scroll: ListState::default(),
show_help: false,
status_message: "Ready".to_string(),
last_execution_time: None,
status_metrics: Some(initial_metrics),
metrics_last_updated: Some(Instant::now()),
panel_visibility: PanelVisibility::default(),
focus_panel: FocusPanel::Input,
tables_browser: TablesBrowserState::default(),
results_table: ResultsTableState::default(),
history_scroll: ListState::default(),
current_keyspace: None,
};
app.load_tables(config).await;
Ok(app)
}
fn metrics_stale(&self) -> bool {
match self.metrics_last_updated {
Some(last) => last.elapsed() > METRICS_REFRESH_INTERVAL,
None => true,
}
}
async fn refresh_metrics(&mut self) {
if self.metrics_stale() {
self.status_metrics =
Some(StatusMetrics::collect(Some(&self.db_path), Some(&self.database)).await);
self.metrics_last_updated = Some(Instant::now());
}
}
async fn load_tables(&mut self, config: &Config) {
let data_dir = match &config.data_directory {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => {
return;
}
};
match self.scan_tables(data_dir).await {
Ok(tables) => {
self.tables_browser.entries = tables;
self.tables_browser.apply_filter();
if !self.tables_browser.filtered_indices.is_empty() {
self.tables_browser.list_state.select(Some(0));
}
}
Err(e) => {
eprintln!("Warning: Failed to load tables: {}", e);
}
}
}
async fn scan_tables(&self, data_dir: &Path) -> Result<Vec<TableEntry>> {
use std::fs;
let mut entries = Vec::new();
let read_dir = fs::read_dir(data_dir)
.map_err(|e| anyhow::anyhow!("Failed to read data directory: {}", e))?;
for entry in read_dir {
let entry =
entry.map_err(|e| anyhow::anyhow!("Failed to read directory entry: {}", e))?;
if !entry.path().is_dir() {
continue;
}
let keyspace_name = match entry.file_name().to_str() {
Some(name) if !name.starts_with('.') && name != "system" => name.to_string(),
_ => continue,
};
let keyspace_dir = entry.path();
let table_read_dir = match fs::read_dir(&keyspace_dir) {
Ok(rd) => rd,
Err(_) => continue, };
for table_entry in table_read_dir {
let table_entry = match table_entry {
Ok(e) => e,
Err(_) => continue,
};
if !table_entry.path().is_dir() {
continue;
}
if let Some(dir_name) = table_entry.file_name().to_str() {
if let Some(table_name) = extract_table_name(dir_name) {
entries.push(TableEntry {
keyspace: keyspace_name.clone(),
name: table_name.clone(),
qualified_name: format!("{}.{}", keyspace_name, table_name),
});
}
}
}
}
entries.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
Ok(entries)
}
async fn execute_query(&mut self) {
if self.input.trim().is_empty() {
return;
}
let query = self.input.trim().to_string();
self.history.push(query.clone());
self.history_index = None;
self.status_message = "Executing query...".to_string();
let start_time = std::time::Instant::now();
match self.database.execute(&query).await {
Ok(result) => {
let execution_time = start_time.elapsed();
self.last_execution_time = Some(execution_time);
let display_result = QueryDisplayResult {
query: query.clone(),
success: true,
rows: result.rows.len(),
execution_time: Some(execution_time),
error_message: None,
};
self.query_results.insert(0, display_result);
if result.rows.is_empty() && result.rows_affected > 0 {
self.messages.push(format!(
"✓ Query executed: {} rows affected ({})",
result.rows_affected,
format_duration(execution_time)
));
self.results_table.clear();
} else {
self.messages.push(format!(
"✓ Query executed: {} rows returned ({})",
result.rows.len(),
format_duration(execution_time)
));
if !result.rows.is_empty() {
let column_names = result.rows[0].column_names();
self.results_table.columns = column_names.clone();
self.results_table.rows = result
.rows
.iter()
.map(|row| {
column_names
.iter()
.map(|col| {
row.get(col)
.map(|v| v.to_string())
.unwrap_or_else(|| "NULL".to_string())
})
.collect()
})
.collect();
self.results_table.row_offset = 0;
self.results_table.col_offset = 0;
self.results_table.calculate_widths();
self.messages
.push(format!("Columns: {}", column_names.join(", ")));
} else {
self.results_table.clear();
}
}
self.status_message =
format!("Query completed in {}", format_duration(execution_time));
}
Err(e) => {
let execution_time = start_time.elapsed();
let display_result = QueryDisplayResult {
query: query.clone(),
success: false,
rows: 0,
execution_time: Some(execution_time),
error_message: Some(e.to_string()),
};
self.query_results.insert(0, display_result);
self.messages.push(format!("✗ Query failed: {}", e));
self.status_message = "Query failed".to_string();
}
}
if self.query_results.len() > 20 {
self.query_results.truncate(20);
}
if self.messages.len() > 100 {
self.messages.drain(0..self.messages.len() - 100);
}
self.input.clear();
}
fn navigate_history(&mut self, up: bool) {
if self.history.is_empty() {
return;
}
if up {
let index = match self.history_index {
None => self.history.len() - 1,
Some(i) if i > 0 => i - 1,
Some(_) => return,
};
self.history_index = Some(index);
self.input = self.history[index].clone();
} else {
match self.history_index {
None => return,
Some(i) if i < self.history.len() - 1 => {
self.history_index = Some(i + 1);
self.input = self.history[i + 1].clone();
}
Some(_) => {
self.history_index = None;
self.input.clear();
}
}
}
}
}
async fn run_tui<B: Backend>(terminal: &mut Terminal<B>, app: &mut TuiApp) -> Result<()> {
loop {
app.refresh_metrics().await;
terminal.draw(|f| ui(f, app))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if handle_key_event(app, key).await {
return Ok(()); }
}
}
}
}
async fn handle_key_event(app: &mut TuiApp, key: event::KeyEvent) -> bool {
if app.show_help {
app.show_help = false;
return false;
}
if app.tables_browser.filter_active {
return handle_filter_key(app, key);
}
match key.code {
KeyCode::F(1) => {
app.show_help = true;
return false;
}
KeyCode::F(2) => {
app.panel_visibility.tables = !app.panel_visibility.tables;
if !app.panel_visibility.tables && app.focus_panel == FocusPanel::Tables {
app.focus_panel = app.focus_panel.next(&app.panel_visibility);
}
return false;
}
KeyCode::F(3) => {
app.panel_visibility.results = !app.panel_visibility.results;
if !app.panel_visibility.results && app.focus_panel == FocusPanel::Results {
app.focus_panel = app.focus_panel.next(&app.panel_visibility);
}
return false;
}
KeyCode::F(4) => {
app.panel_visibility.history = !app.panel_visibility.history;
if !app.panel_visibility.history && app.focus_panel == FocusPanel::History {
app.focus_panel = app.focus_panel.next(&app.panel_visibility);
}
return false;
}
KeyCode::F(5) => {
app.panel_visibility.reset();
return false;
}
KeyCode::Esc => {
return true; }
KeyCode::Tab => {
app.focus_panel = app.focus_panel.next(&app.panel_visibility);
return false;
}
KeyCode::BackTab => {
app.focus_panel = app.focus_panel.prev(&app.panel_visibility);
return false;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return true; }
KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.messages.clear();
app.query_results.clear();
app.results_table.clear();
app.status_message = "Screen cleared".to_string();
return false;
}
KeyCode::Char('1')
if key.modifiers.is_empty()
&& app.panel_visibility.tables
&& app.focus_panel != FocusPanel::Input =>
{
app.focus_panel = FocusPanel::Tables;
return false;
}
KeyCode::Char('2')
if key.modifiers.is_empty()
&& app.panel_visibility.results
&& app.focus_panel != FocusPanel::Input =>
{
app.focus_panel = FocusPanel::Results;
return false;
}
KeyCode::Char('3')
if key.modifiers.is_empty()
&& app.panel_visibility.history
&& app.focus_panel != FocusPanel::Input =>
{
app.focus_panel = FocusPanel::History;
return false;
}
_ => {}
}
match app.focus_panel {
FocusPanel::Tables => handle_tables_key(app, key).await,
FocusPanel::Results => handle_results_key(app, key),
FocusPanel::History => handle_history_key(app, key),
FocusPanel::Input => handle_input_key(app, key).await,
}
false
}
fn handle_filter_key(app: &mut TuiApp, key: event::KeyEvent) -> bool {
match key.code {
KeyCode::Enter | KeyCode::Esc => {
app.tables_browser.filter_active = false;
}
KeyCode::Char(c) => {
app.tables_browser.filter_text.push(c);
app.tables_browser.apply_filter();
}
KeyCode::Backspace => {
app.tables_browser.filter_text.pop();
app.tables_browser.apply_filter();
}
_ => {}
}
false
}
async fn handle_tables_key(app: &mut TuiApp, key: event::KeyEvent) {
let browser = &mut app.tables_browser;
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
let selected = browser.list_state.selected().unwrap_or(0);
if selected < browser.filtered_indices.len().saturating_sub(1) {
browser.list_state.select(Some(selected + 1));
}
}
KeyCode::Char('k') | KeyCode::Up => {
let selected = browser.list_state.selected().unwrap_or(0);
if selected > 0 {
browser.list_state.select(Some(selected - 1));
}
}
KeyCode::Char('/') => {
browser.filter_active = true;
}
KeyCode::Enter => {
if let Some(entry) = browser.selected_entry().cloned() {
app.input = format!("SELECT * FROM {} LIMIT 100", entry.qualified_name);
app.focus_panel = FocusPanel::Input;
}
}
KeyCode::Char('d') => {
if let Some(entry) = browser.selected_entry().cloned() {
app.input = format!("DESCRIBE {}", entry.qualified_name);
app.focus_panel = FocusPanel::Input;
}
}
KeyCode::Char('g') => {
browser.list_state.select(Some(0));
}
KeyCode::Char('G') => {
let last = browser.filtered_indices.len().saturating_sub(1);
browser.list_state.select(Some(last));
}
_ => {}
}
}
fn handle_results_key(app: &mut TuiApp, key: event::KeyEvent) {
let results = &mut app.results_table;
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
if results.row_offset < results.rows.len().saturating_sub(1) {
results.row_offset += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
if results.row_offset > 0 {
results.row_offset -= 1;
}
}
KeyCode::Char('h') | KeyCode::Left => {
if results.col_offset > 0 {
results.col_offset -= 1;
}
}
KeyCode::Char('l') | KeyCode::Right => {
if results.col_offset < results.columns.len().saturating_sub(1) {
results.col_offset += 1;
}
}
KeyCode::Char('g') => {
results.row_offset = 0;
results.col_offset = 0;
}
KeyCode::Char('G') => {
results.row_offset = results.rows.len().saturating_sub(10);
}
KeyCode::PageUp => {
results.row_offset = results.row_offset.saturating_sub(20);
}
KeyCode::PageDown => {
let max_offset = results.rows.len().saturating_sub(10);
results.row_offset = (results.row_offset + 20).min(max_offset);
}
_ => {}
}
}
fn handle_history_key(app: &mut TuiApp, key: event::KeyEvent) {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
let selected = app.history_scroll.selected().unwrap_or(0);
if selected < app.query_results.len().saturating_sub(1) {
app.history_scroll.select(Some(selected + 1));
}
}
KeyCode::Char('k') | KeyCode::Up => {
let selected = app.history_scroll.selected().unwrap_or(0);
if selected > 0 {
app.history_scroll.select(Some(selected - 1));
}
}
KeyCode::Enter => {
if let Some(selected) = app.history_scroll.selected() {
if let Some(result) = app.query_results.get(selected) {
app.input = result.query.clone();
app.focus_panel = FocusPanel::Input;
}
}
}
KeyCode::Char('g') => {
app.history_scroll.select(Some(0));
}
KeyCode::Char('G') => {
let last = app.query_results.len().saturating_sub(1);
app.history_scroll.select(Some(last));
}
_ => {}
}
}
async fn handle_input_key(app: &mut TuiApp, key: event::KeyEvent) {
match key.code {
KeyCode::Enter => {
app.execute_query().await;
}
KeyCode::Char(c) => {
app.input.push(c);
app.history_index = None;
}
KeyCode::Backspace => {
app.input.pop();
app.history_index = None;
}
KeyCode::Up => {
app.navigate_history(true);
}
KeyCode::Down => {
app.navigate_history(false);
}
_ => {}
}
}
fn build_layout(area: Rect, visibility: &PanelVisibility) -> LayoutAreas {
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.margin(0)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), Constraint::Length(3), ])
.split(area);
let main_area = vertical_chunks[1];
let (tables_area, right_area) = if visibility.tables {
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25), Constraint::Percentage(75), ])
.split(main_area);
(Some(h_chunks[0]), h_chunks[1])
} else {
(None, main_area)
};
let (results_area, history_area) = match (visibility.results, visibility.history) {
(true, true) => {
let v_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(65), Constraint::Percentage(35), ])
.split(right_area);
(Some(v_chunks[0]), Some(v_chunks[1]))
}
(true, false) => (Some(right_area), None),
(false, true) => (None, Some(right_area)),
(false, false) => (None, None),
};
LayoutAreas {
header: vertical_chunks[0],
tables: tables_area,
results: results_area,
history: history_area,
input: vertical_chunks[2],
status: vertical_chunks[3],
}
}
fn render_tables_panel(f: &mut Frame, area: Rect, app: &mut TuiApp) {
let is_focused = app.focus_panel == FocusPanel::Tables;
let border_style = if is_focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let (filter_area, list_area) =
if app.tables_browser.filter_active || !app.tables_browser.filter_text.is_empty() {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(area);
(Some(chunks[0]), chunks[1])
} else {
(None, area)
};
if let Some(filter_rect) = filter_area {
let filter_border = if app.tables_browser.filter_active {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let filter = Paragraph::new(app.tables_browser.filter_text.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.title("Filter (/)")
.border_style(filter_border),
)
.style(if app.tables_browser.filter_active {
Style::default().fg(Color::Yellow)
} else {
Style::default()
});
f.render_widget(filter, filter_rect);
if app.tables_browser.filter_active {
f.set_cursor(
filter_rect.x + app.tables_browser.filter_text.len() as u16 + 1,
filter_rect.y + 1,
);
}
}
let items: Vec<ListItem> = app
.tables_browser
.filtered_indices
.iter()
.map(|&idx| {
if let Some(entry) = app.tables_browser.entries.get(idx) {
ListItem::new(Line::from(vec![
Span::styled("+ ", Style::default().fg(Color::Green)),
Span::raw(entry.qualified_name.clone()),
]))
} else {
ListItem::new(Line::from(""))
}
})
.collect();
let title = format!(
"Tables [1] ({}/{})",
app.tables_browser.filtered_indices.len(),
app.tables_browser.entries.len()
);
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style),
)
.highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.fg(Color::Cyan),
)
.highlight_symbol("> ");
f.render_stateful_widget(list, list_area, &mut app.tables_browser.list_state);
}
fn render_results_panel(f: &mut Frame, area: Rect, app: &mut TuiApp) {
let is_focused = app.focus_panel == FocusPanel::Results;
let border_style = if is_focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
if app.results_table.columns.is_empty() {
let messages: Vec<ListItem> = app
.messages
.iter()
.enumerate()
.map(|(i, m)| {
let content = Line::from(Span::raw(format!("{}: {}", i + 1, m)));
ListItem::new(content)
})
.collect();
let empty_widget = List::new(messages).block(
Block::default()
.borders(Borders::ALL)
.title("Query Results [2]")
.border_style(border_style),
);
f.render_widget(empty_widget, area);
return;
}
let inner_width = area.width.saturating_sub(4); let visible_cols = app.results_table.visible_columns(inner_width);
let column_widths: Vec<u16> = app.results_table.column_widths.clone();
let header_cells: Vec<Cell> = app.results_table.columns[visible_cols.clone()]
.iter()
.enumerate()
.map(|(idx, h)| {
let col_idx = visible_cols.start + idx;
let col_width = column_widths.get(col_idx).copied().unwrap_or(10) as usize;
let max_chars = col_width.saturating_sub(2);
let truncated = if h.len() > max_chars {
format!("{}…", &h[..max_chars.saturating_sub(1)])
} else {
h.clone()
};
Cell::from(truncated).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
})
.collect();
let header = Row::new(header_cells).height(1);
let visible_height = area.height.saturating_sub(4) as usize; let row_offset = app.results_table.row_offset;
let rows: Vec<Row> = app
.results_table
.rows
.iter()
.skip(row_offset)
.take(visible_height)
.map(|row| {
let cells: Vec<Cell> = visible_cols
.clone()
.enumerate()
.filter_map(|(_idx, i)| {
row.get(i).map(|cell_content| {
let col_width = column_widths.get(i).copied().unwrap_or(10) as usize;
let max_chars = col_width.saturating_sub(2);
let truncated = if cell_content.len() > max_chars {
format!("{}…", &cell_content[..max_chars.saturating_sub(1)])
} else {
cell_content.clone()
};
Cell::from(truncated)
})
})
.collect();
Row::new(cells)
})
.collect();
let widths: Vec<Constraint> = visible_cols
.clone()
.filter_map(|i| column_widths.get(i).map(|&w| Constraint::Length(w)))
.collect();
let has_left = app.results_table.has_scroll_left();
let has_right = app.results_table.has_scroll_right(inner_width);
let num_cols = app.results_table.columns.len();
let num_rows = app.results_table.rows.len();
let scroll_hint = if has_left || has_right {
format!(
" (cols {}-{}/{}) ",
visible_cols.start + 1,
visible_cols.end,
num_cols
)
} else {
String::new()
};
let row_hint = if num_rows > visible_height {
format!(
" rows {}-{}/{}",
row_offset + 1,
(row_offset + visible_height).min(num_rows),
num_rows
)
} else {
format!(" {} rows", num_rows)
};
let title = format!("Query Results [2]{}{}", scroll_hint, row_hint);
let table = Table::new(rows)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style),
)
.widths(&widths)
.column_spacing(1) .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol(">> ");
f.render_stateful_widget(table, area, &mut app.results_table.table_state);
}
fn render_history_panel(f: &mut Frame, area: Rect, app: &mut TuiApp) {
let is_focused = app.focus_panel == FocusPanel::History;
let border_style = if is_focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let items: Vec<ListItem> = app
.query_results
.iter()
.map(|result| {
let status = if result.success { "✓" } else { "✗" };
let time_str = result
.execution_time
.map(|t| format_duration(t))
.unwrap_or_else(|| "--".to_string());
let status_style = if result.success {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Red)
};
let query_text = if result.query.len() > 60 {
format!("{}…", &result.query[..59])
} else {
result.query.clone()
};
let line = Line::from(vec![
Span::styled(status, status_style),
Span::raw(" "),
Span::styled(time_str, Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::raw(query_text),
]);
ListItem::new(line)
})
.collect();
let title = format!("Query History [3] ({})", app.query_results.len());
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style),
)
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::DarkGray),
)
.highlight_symbol("> ");
f.render_stateful_widget(list, area, &mut app.history_scroll);
}
fn render_header(f: &mut Frame, area: Rect, app: &TuiApp) {
let keyspace_text = app
.current_keyspace
.as_ref()
.map(|ks| format!("[{}]", ks))
.unwrap_or_else(|| "[no keyspace]".to_string());
let header = Paragraph::new(vec![
Line::from(vec![
Span::styled(
"CQLite TUI v0.1.0",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(keyspace_text, Style::default().fg(Color::Yellow)),
Span::raw(" "),
Span::styled("F1:Help", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled("F2-F4:Toggle", Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::styled("Esc:Exit", Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::raw("Database: "),
Span::styled(
app.db_path.display().to_string(),
Style::default().fg(Color::Green),
),
]),
])
.block(Block::default().borders(Borders::ALL));
f.render_widget(header, area);
}
fn render_input(f: &mut Frame, area: Rect, app: &TuiApp) {
let is_focused = app.focus_panel == FocusPanel::Input;
let border_style = if is_focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
let input = Paragraph::new(app.input.as_str())
.style(if is_focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
})
.block(
Block::default()
.borders(Borders::ALL)
.title("CQL> ")
.border_style(border_style),
);
f.render_widget(input, area);
if is_focused && !app.tables_browser.filter_active {
f.set_cursor(area.x + app.input.len() as u16 + 1, area.y + 1);
}
}
fn render_status(f: &mut Frame, area: Rect, app: &TuiApp) {
let (health_text, health_color) = match app.status_metrics.as_ref() {
Some(metrics) => match metrics.health {
HealthIndicator::Ok => ("OK", Color::Green),
HealthIndicator::Warning => ("WARN", Color::Yellow),
HealthIndicator::Error => ("ERR", Color::Red),
},
None => ("--", Color::DarkGray),
};
let memory_text = app
.status_metrics
.as_ref()
.map(|m| m.format_memory())
.unwrap_or_else(|| "--".to_string());
let data_text = app
.status_metrics
.as_ref()
.map(|m| m.format_data())
.unwrap_or_else(|| "--".to_string());
let mode_text = match app.focus_panel {
FocusPanel::Tables => "TABLES",
FocusPanel::Results => "RESULTS",
FocusPanel::History => "HISTORY",
FocusPanel::Input => "INPUT",
};
let status_line = Line::from(vec![
Span::raw("Health: "),
Span::styled(health_text, Style::default().fg(health_color)),
Span::raw(" | Mem: "),
Span::styled(&memory_text, Style::default().fg(Color::Cyan)),
Span::raw(" | Data: "),
Span::styled(&data_text, Style::default().fg(Color::Cyan)),
Span::raw(" | Status: "),
Span::styled(&app.status_message, Style::default().fg(Color::Green)),
Span::raw(" | Mode: "),
Span::styled(mode_text, Style::default().fg(Color::Cyan)),
]);
let status = Paragraph::new(status_line).block(Block::default().borders(Borders::ALL));
f.render_widget(status, area);
}
fn ui(f: &mut Frame, app: &mut TuiApp) {
if app.show_help {
draw_help(f);
return;
}
let layout = build_layout(f.size(), &app.panel_visibility);
render_header(f, layout.header, app);
if let Some(tables_area) = layout.tables {
render_tables_panel(f, tables_area, app);
}
if let Some(results_area) = layout.results {
render_results_panel(f, results_area, app);
}
if let Some(history_area) = layout.history {
render_history_panel(f, history_area, app);
}
if layout.tables.is_none() && layout.results.is_none() && layout.history.is_none() {
let main_area = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(3),
])
.split(f.size())[1];
let msg = Paragraph::new("Press F2/F3/F4 to show panels, or F5 to reset layout")
.block(Block::default().borders(Borders::ALL).title("No Panels"))
.style(Style::default().fg(Color::DarkGray));
f.render_widget(msg, main_area);
}
render_input(f, layout.input, app);
render_status(f, layout.status, app);
}
fn draw_help(f: &mut Frame) {
let help_text = vec![
Line::from(Span::styled(
"CQLite TUI Help (Issue #251)",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Global Commands:",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" F1 Toggle this help screen"),
Line::from(" F2 Toggle Tables panel"),
Line::from(" F3 Toggle Results panel"),
Line::from(" F4 Toggle History panel"),
Line::from(" F5 Reset layout (show all panels)"),
Line::from(" Tab Cycle focus to next panel"),
Line::from(" Shift+Tab Cycle focus to previous panel"),
Line::from(" 1/2/3 Jump directly to panel"),
Line::from(" Esc Exit application"),
Line::from(" Ctrl+C Quit immediately"),
Line::from(" Ctrl+L Clear screen and history"),
Line::from(""),
Line::from(Span::styled(
"Tables Panel [1]:",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" j/k, Up/Down Navigate tables"),
Line::from(" / Open filter input"),
Line::from(" Enter Query selected table"),
Line::from(" d Describe selected table"),
Line::from(" g/G Jump to first/last table"),
Line::from(""),
Line::from(Span::styled(
"Results Panel [2]:",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" j/k, Up/Down Scroll rows"),
Line::from(" h/l, Left/Right Scroll columns (horizontal)"),
Line::from(" g/G Jump to first/last row"),
Line::from(" PgUp/PgDn Page up/down"),
Line::from(""),
Line::from(Span::styled(
"History Panel [3]:",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" j/k, Up/Down Navigate history"),
Line::from(" Enter Copy query to input"),
Line::from(" g/G Jump to first/last entry"),
Line::from(""),
Line::from(Span::styled(
"Input Panel:",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" Enter Execute current query"),
Line::from(" Up/Down Navigate command history"),
Line::from(" Backspace Delete character"),
Line::from(""),
Line::from(Span::styled(
"Press any key to close this help",
Style::default().fg(Color::DarkGray),
)),
];
let help_paragraph = Paragraph::new(help_text)
.block(
Block::default()
.borders(Borders::ALL)
.title("Help - Press any key to close")
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: true });
let area = centered_rect(85, 95, f.size());
f.render_widget(help_paragraph, area);
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
fn format_duration(duration: Duration) -> String {
let micros = duration.as_micros();
if micros < 1_000 {
format!("{}μs", micros)
} else if micros < 1_000_000 {
format!("{:.1}ms", micros as f64 / 1_000.0)
} else {
format!("{:.1}s", micros as f64 / 1_000_000.0)
}
}
fn extract_table_name(dir_name: &str) -> Option<String> {
if let Some(dash_pos) = dir_name.find('-') {
let table_part = &dir_name[..dash_pos];
if !table_part.is_empty() && table_part.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Some(table_part.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_focus_panel_next_all_visible() {
let visibility = PanelVisibility {
tables: true,
results: true,
history: true,
};
assert_eq!(FocusPanel::Tables.next(&visibility), FocusPanel::Results);
assert_eq!(FocusPanel::Results.next(&visibility), FocusPanel::History);
assert_eq!(FocusPanel::History.next(&visibility), FocusPanel::Input);
assert_eq!(FocusPanel::Input.next(&visibility), FocusPanel::Tables);
}
#[test]
fn test_focus_panel_prev_all_visible() {
let visibility = PanelVisibility {
tables: true,
results: true,
history: true,
};
assert_eq!(FocusPanel::Tables.prev(&visibility), FocusPanel::Input);
assert_eq!(FocusPanel::Input.prev(&visibility), FocusPanel::History);
assert_eq!(FocusPanel::History.prev(&visibility), FocusPanel::Results);
assert_eq!(FocusPanel::Results.prev(&visibility), FocusPanel::Tables);
}
#[test]
fn test_focus_panel_next_skips_hidden_tables() {
let visibility = PanelVisibility {
tables: false,
results: true,
history: true,
};
assert_eq!(FocusPanel::Input.next(&visibility), FocusPanel::Results);
assert_eq!(FocusPanel::Results.next(&visibility), FocusPanel::History);
assert_eq!(FocusPanel::History.next(&visibility), FocusPanel::Input);
}
#[test]
fn test_focus_panel_next_skips_hidden_results() {
let visibility = PanelVisibility {
tables: true,
results: false,
history: true,
};
assert_eq!(FocusPanel::Tables.next(&visibility), FocusPanel::History);
assert_eq!(FocusPanel::History.next(&visibility), FocusPanel::Input);
assert_eq!(FocusPanel::Input.next(&visibility), FocusPanel::Tables);
}
#[test]
fn test_focus_panel_next_skips_hidden_history() {
let visibility = PanelVisibility {
tables: true,
results: true,
history: false,
};
assert_eq!(FocusPanel::Results.next(&visibility), FocusPanel::Input);
assert_eq!(FocusPanel::Input.next(&visibility), FocusPanel::Tables);
assert_eq!(FocusPanel::Tables.next(&visibility), FocusPanel::Results);
}
#[test]
fn test_focus_panel_next_only_input_visible() {
let visibility = PanelVisibility {
tables: false,
results: false,
history: false,
};
assert_eq!(FocusPanel::Input.next(&visibility), FocusPanel::Input);
assert_eq!(FocusPanel::Tables.next(&visibility), FocusPanel::Input);
assert_eq!(FocusPanel::Results.next(&visibility), FocusPanel::Input);
assert_eq!(FocusPanel::History.next(&visibility), FocusPanel::Input);
}
#[test]
fn test_focus_panel_prev_skips_hidden_panels() {
let visibility = PanelVisibility {
tables: false,
results: true,
history: false,
};
assert_eq!(FocusPanel::Results.prev(&visibility), FocusPanel::Input);
assert_eq!(FocusPanel::Input.prev(&visibility), FocusPanel::Results);
}
#[test]
fn test_focus_panel_input_always_reachable() {
let visibility = PanelVisibility {
tables: false,
results: false,
history: false,
};
let mut current = FocusPanel::Tables;
for _ in 0..5 {
current = current.next(&visibility);
}
assert_eq!(current, FocusPanel::Input);
}
#[test]
fn test_apply_filter_empty_shows_all() {
let mut state = TablesBrowserState::default();
state.entries = vec![
TableEntry {
keyspace: "ks1".to_string(),
name: "table1".to_string(),
qualified_name: "ks1.table1".to_string(),
},
TableEntry {
keyspace: "ks2".to_string(),
name: "table2".to_string(),
qualified_name: "ks2.table2".to_string(),
},
TableEntry {
keyspace: "ks3".to_string(),
name: "users".to_string(),
qualified_name: "ks3.users".to_string(),
},
];
state.filter_text = String::new();
state.apply_filter();
assert_eq!(state.filtered_indices, vec![0, 1, 2]);
}
#[test]
fn test_apply_filter_matches_some() {
let mut state = TablesBrowserState::default();
state.entries = vec![
TableEntry {
keyspace: "ks1".to_string(),
name: "table1".to_string(),
qualified_name: "ks1.table1".to_string(),
},
TableEntry {
keyspace: "ks2".to_string(),
name: "table2".to_string(),
qualified_name: "ks2.table2".to_string(),
},
TableEntry {
keyspace: "ks3".to_string(),
name: "users".to_string(),
qualified_name: "ks3.users".to_string(),
},
];
state.filter_text = "table".to_string();
state.apply_filter();
assert_eq!(state.filtered_indices, vec![0, 1]);
}
#[test]
fn test_apply_filter_matches_none() {
let mut state = TablesBrowserState::default();
state.entries = vec![
TableEntry {
keyspace: "ks1".to_string(),
name: "table1".to_string(),
qualified_name: "ks1.table1".to_string(),
},
TableEntry {
keyspace: "ks2".to_string(),
name: "table2".to_string(),
qualified_name: "ks2.table2".to_string(),
},
];
state.filter_text = "nonexistent".to_string();
state.apply_filter();
assert_eq!(state.filtered_indices, Vec::<usize>::new());
}
#[test]
fn test_apply_filter_case_insensitive() {
let mut state = TablesBrowserState::default();
state.entries = vec![
TableEntry {
keyspace: "TestKS".to_string(),
name: "Users".to_string(),
qualified_name: "TestKS.Users".to_string(),
},
TableEntry {
keyspace: "prodks".to_string(),
name: "products".to_string(),
qualified_name: "prodks.products".to_string(),
},
];
state.filter_text = "USERS".to_string();
state.apply_filter();
assert_eq!(state.filtered_indices, vec![0]);
}
#[test]
fn test_apply_filter_resets_selection_when_out_of_bounds() {
let mut state = TablesBrowserState::default();
state.entries = vec![
TableEntry {
keyspace: "ks1".to_string(),
name: "table1".to_string(),
qualified_name: "ks1.table1".to_string(),
},
TableEntry {
keyspace: "ks2".to_string(),
name: "table2".to_string(),
qualified_name: "ks2.table2".to_string(),
},
TableEntry {
keyspace: "ks3".to_string(),
name: "users".to_string(),
qualified_name: "ks3.users".to_string(),
},
];
state.filtered_indices = vec![0, 1, 2];
state.list_state.select(Some(2));
state.filter_text = "table".to_string();
state.apply_filter();
assert_eq!(state.list_state.selected(), Some(0));
assert_eq!(state.filtered_indices, vec![0, 1]);
}
#[test]
fn test_apply_filter_resets_selection_when_empty() {
let mut state = TablesBrowserState::default();
state.entries = vec![TableEntry {
keyspace: "ks1".to_string(),
name: "table1".to_string(),
qualified_name: "ks1.table1".to_string(),
}];
state.filtered_indices = vec![0];
state.list_state.select(Some(0));
state.filter_text = "nonexistent".to_string();
state.apply_filter();
assert_eq!(state.list_state.selected(), None);
assert_eq!(state.filtered_indices, Vec::<usize>::new());
}
#[test]
fn test_calculate_widths_empty_columns() {
let mut state = ResultsTableState::default();
state.calculate_widths();
assert_eq!(state.column_widths, Vec::<u16>::new());
}
#[test]
fn test_calculate_widths_headers_only() {
let mut state = ResultsTableState::default();
state.columns = vec!["id".to_string(), "name".to_string(), "email".to_string()];
state.calculate_widths();
assert_eq!(state.column_widths, vec![4, 6, 7]);
}
#[test]
fn test_calculate_widths_with_data() {
let mut state = ResultsTableState::default();
state.columns = vec!["id".to_string(), "name".to_string()];
state.rows = vec![
vec!["1".to_string(), "Alice".to_string()],
vec!["2".to_string(), "BobTheBuilder".to_string()],
];
state.calculate_widths();
assert_eq!(state.column_widths, vec![4, 15]);
}
#[test]
fn test_calculate_widths_caps_at_40() {
let mut state = ResultsTableState::default();
state.columns = vec!["long_column".to_string()];
state.rows = vec![vec!["a".repeat(100)]];
state.calculate_widths();
assert_eq!(state.column_widths, vec![40]);
}
#[test]
fn test_calculate_widths_samples_first_100_rows() {
let mut state = ResultsTableState::default();
state.columns = vec!["data".to_string()];
let mut rows = Vec::new();
for i in 0..150 {
if i == 101 {
rows.push(vec!["very_long_content_here".to_string()]);
} else {
rows.push(vec!["x".to_string()]);
}
}
state.rows = rows;
state.calculate_widths();
assert_eq!(state.column_widths, vec![6]);
}
#[test]
fn test_visible_columns_empty() {
let state = ResultsTableState::default();
let visible = state.visible_columns(100);
assert_eq!(visible, 0..0);
}
#[test]
fn test_visible_columns_all_fit() {
let mut state = ResultsTableState::default();
state.columns = vec!["a".to_string(), "b".to_string(), "c".to_string()];
state.column_widths = vec![10, 10, 10];
state.col_offset = 0;
let visible = state.visible_columns(50);
assert_eq!(visible, 0..3);
}
#[test]
fn test_visible_columns_with_offset() {
let mut state = ResultsTableState::default();
state.columns = vec!["a".to_string(), "b".to_string(), "c".to_string()];
state.column_widths = vec![10, 10, 10];
state.col_offset = 1;
let visible = state.visible_columns(50);
assert_eq!(visible, 1..3);
}
#[test]
fn test_visible_columns_partial_fit() {
let mut state = ResultsTableState::default();
state.columns = vec!["a".to_string(), "b".to_string(), "c".to_string()];
state.column_widths = vec![15, 15, 15];
state.col_offset = 0;
let visible = state.visible_columns(25);
assert_eq!(visible, 0..1);
}
#[test]
fn test_visible_columns_clamps_offset() {
let mut state = ResultsTableState::default();
state.columns = vec!["a".to_string(), "b".to_string()];
state.column_widths = vec![10, 10];
state.col_offset = 100;
let visible = state.visible_columns(50);
assert_eq!(visible, 1..2);
}
#[test]
fn test_has_scroll_left() {
let mut state = ResultsTableState::default();
state.col_offset = 0;
assert!(!state.has_scroll_left());
state.col_offset = 1;
assert!(state.has_scroll_left());
state.col_offset = 5;
assert!(state.has_scroll_left());
}
#[test]
fn test_has_scroll_right() {
let mut state = ResultsTableState::default();
state.columns = vec!["a".to_string(), "b".to_string(), "c".to_string()];
state.column_widths = vec![20, 20, 20];
state.col_offset = 0;
assert!(state.has_scroll_right(30));
assert!(!state.has_scroll_right(100));
}
#[test]
fn test_has_scroll_right_at_end() {
let mut state = ResultsTableState::default();
state.columns = vec!["a".to_string(), "b".to_string()];
state.column_widths = vec![10, 10];
state.col_offset = 1;
assert!(!state.has_scroll_right(100));
}
#[test]
fn test_clear() {
let mut state = ResultsTableState::default();
state.columns = vec!["a".to_string(), "b".to_string()];
state.rows = vec![vec!["1".to_string(), "2".to_string()]];
state.row_offset = 5;
state.col_offset = 2;
state.selected_row = Some(3);
state.column_widths = vec![10, 20];
state.clear();
assert!(state.columns.is_empty());
assert!(state.rows.is_empty());
assert_eq!(state.row_offset, 0);
assert_eq!(state.col_offset, 0);
assert_eq!(state.selected_row, None);
assert!(state.column_widths.is_empty());
}
#[test]
fn test_panel_visibility_default() {
let visibility = PanelVisibility::default();
assert!(visibility.tables);
assert!(visibility.results);
assert!(visibility.history);
}
#[test]
fn test_panel_visibility_reset() {
let mut visibility = PanelVisibility {
tables: false,
results: false,
history: false,
};
visibility.reset();
assert!(visibility.tables);
assert!(visibility.results);
assert!(visibility.history);
}
#[test]
fn test_panel_visibility_reset_restores_default() {
let mut visibility = PanelVisibility {
tables: true,
results: false,
history: true,
};
visibility.reset();
let default = PanelVisibility::default();
assert_eq!(visibility.tables, default.tables);
assert_eq!(visibility.results, default.results);
assert_eq!(visibility.history, default.history);
}
#[test]
fn test_format_duration_microseconds() {
assert_eq!(format_duration(Duration::from_micros(0)), "0μs");
assert_eq!(format_duration(Duration::from_micros(1)), "1μs");
assert_eq!(format_duration(Duration::from_micros(450)), "450μs");
assert_eq!(format_duration(Duration::from_micros(999)), "999μs");
}
#[test]
fn test_format_duration_milliseconds() {
assert_eq!(format_duration(Duration::from_micros(1_000)), "1.0ms");
assert_eq!(format_duration(Duration::from_micros(1_200)), "1.2ms");
assert_eq!(format_duration(Duration::from_micros(7_000)), "7.0ms");
assert_eq!(format_duration(Duration::from_micros(74_000)), "74.0ms");
assert_eq!(format_duration(Duration::from_micros(123_456)), "123.5ms");
assert_eq!(format_duration(Duration::from_micros(999_999)), "1000.0ms");
}
#[test]
fn test_format_duration_seconds() {
assert_eq!(format_duration(Duration::from_micros(1_000_000)), "1.0s");
assert_eq!(format_duration(Duration::from_micros(1_500_000)), "1.5s");
assert_eq!(format_duration(Duration::from_micros(2_750_000)), "2.8s");
assert_eq!(format_duration(Duration::from_micros(10_000_000)), "10.0s");
assert_eq!(
format_duration(Duration::from_micros(123_456_789)),
"123.5s"
);
}
#[test]
fn test_format_duration_boundary_cases() {
assert_eq!(format_duration(Duration::from_nanos(999_999)), "999μs");
assert_eq!(format_duration(Duration::from_nanos(1_000_000)), "1.0ms");
assert_eq!(
format_duration(Duration::from_nanos(999_999_999)),
"1000.0ms"
);
assert_eq!(format_duration(Duration::from_nanos(1_000_000_000)), "1.0s");
}
#[test]
fn test_format_duration_typical_query_times() {
assert_eq!(format_duration(Duration::from_micros(500)), "500μs"); assert_eq!(format_duration(Duration::from_micros(3_500)), "3.5ms"); assert_eq!(format_duration(Duration::from_micros(25_000)), "25.0ms"); assert_eq!(format_duration(Duration::from_micros(150_000)), "150.0ms"); }
#[test]
fn test_navigate_history_empty() {
let mut app_state = create_test_app_state();
app_state.navigate_history(true);
assert_eq!(app_state.input, "");
assert_eq!(app_state.history_index, None);
}
#[test]
fn test_navigate_history_up_from_fresh() {
let mut app_state = create_test_app_state();
app_state.history.push("SELECT * FROM users".to_string());
app_state.history.push("SELECT * FROM orders".to_string());
app_state.history.push("SELECT * FROM products".to_string());
app_state.navigate_history(true);
assert_eq!(app_state.input, "SELECT * FROM products");
assert_eq!(app_state.history_index, Some(2));
app_state.navigate_history(true);
assert_eq!(app_state.input, "SELECT * FROM orders");
assert_eq!(app_state.history_index, Some(1));
app_state.navigate_history(true);
assert_eq!(app_state.input, "SELECT * FROM users");
assert_eq!(app_state.history_index, Some(0));
app_state.navigate_history(true);
assert_eq!(app_state.input, "SELECT * FROM users");
assert_eq!(app_state.history_index, Some(0));
}
#[test]
fn test_navigate_history_down() {
let mut app_state = create_test_app_state();
app_state.history.push("command1".to_string());
app_state.history.push("command2".to_string());
app_state.history.push("command3".to_string());
app_state.navigate_history(true);
app_state.navigate_history(true);
assert_eq!(app_state.history_index, Some(1));
assert_eq!(app_state.input, "command2");
app_state.navigate_history(false);
assert_eq!(app_state.input, "command3");
assert_eq!(app_state.history_index, Some(2));
app_state.navigate_history(false);
assert_eq!(app_state.input, "");
assert_eq!(app_state.history_index, None);
app_state.navigate_history(false);
assert_eq!(app_state.input, "");
assert_eq!(app_state.history_index, None);
}
#[test]
fn test_navigate_history_cycle() {
let mut app_state = create_test_app_state();
app_state.history.push("first".to_string());
app_state.history.push("second".to_string());
app_state.navigate_history(true);
app_state.navigate_history(true);
assert_eq!(app_state.input, "first");
app_state.navigate_history(false);
assert_eq!(app_state.input, "second");
app_state.navigate_history(false);
assert_eq!(app_state.input, "");
assert_eq!(app_state.history_index, None);
app_state.navigate_history(true);
assert_eq!(app_state.input, "second");
assert_eq!(app_state.history_index, Some(1));
}
struct TestHistoryState {
input: String,
history: Vec<String>,
history_index: Option<usize>,
}
impl TestHistoryState {
fn new() -> Self {
Self {
input: String::new(),
history: Vec::new(),
history_index: None,
}
}
fn navigate_history(&mut self, up: bool) {
if self.history.is_empty() {
return;
}
if up {
let index = match self.history_index {
None => self.history.len() - 1,
Some(i) if i > 0 => i - 1,
Some(_) => return,
};
self.history_index = Some(index);
self.input = self.history[index].clone();
} else {
match self.history_index {
None => return,
Some(i) if i < self.history.len() - 1 => {
self.history_index = Some(i + 1);
self.input = self.history[i + 1].clone();
}
Some(_) => {
self.history_index = None;
self.input.clear();
}
}
}
}
}
fn create_test_app_state() -> TestHistoryState {
TestHistoryState::new()
}
#[test]
fn test_extract_table_name_valid() {
assert_eq!(
extract_table_name("users-3b7a9d8c"),
Some("users".to_string())
);
assert_eq!(
extract_table_name("test_table-abc123"),
Some("test_table".to_string())
);
assert_eq!(
extract_table_name("MyTable123-uuid"),
Some("MyTable123".to_string())
);
}
#[test]
fn test_extract_table_name_invalid() {
assert_eq!(extract_table_name("users"), None);
assert_eq!(extract_table_name("-uuid"), None);
assert_eq!(extract_table_name("table.name-uuid"), None);
assert_eq!(
extract_table_name("table-name-uuid"),
Some("table".to_string())
); }
#[test]
fn test_extract_table_name_edge_cases() {
assert_eq!(
extract_table_name("my-table-uuid-1234"),
Some("my".to_string())
);
assert_eq!(extract_table_name("-"), None);
assert_eq!(extract_table_name(""), None);
}
}