Skip to main content

cqlite_cli/
tui.rs

1use crate::config::Config;
2use crate::status_metrics::{HealthIndicator, StatusMetrics, METRICS_REFRESH_INTERVAL};
3use anyhow::Result;
4use cqlite_core::Database;
5use crossterm::{
6    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
7    execute,
8    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
9};
10use ratatui::{
11    backend::{Backend, CrosstermBackend},
12    layout::{Constraint, Direction, Layout, Rect},
13    style::{Color, Modifier, Style},
14    text::{Line, Span},
15    widgets::{
16        Block, Borders, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState, Wrap,
17    },
18    Frame, Terminal,
19};
20use std::path::Path;
21use std::sync::Arc;
22use std::time::{Duration, Instant};
23
24pub async fn start_tui_mode(db_path: &Path, config: &Config, database: Database) -> Result<()> {
25    // CRITICAL: Disable all logging to prevent messages from bleeding into TUI display
26    // env_logger is already initialized in main.rs, so we can't reinitialize it.
27    // Instead, set the global max log level to Off to suppress all log output.
28    log::set_max_level(log::LevelFilter::Off);
29
30    // Initialize the database
31    let db = Arc::new(database);
32
33    // Setup terminal
34    enable_raw_mode()?;
35    let mut stdout = std::io::stdout();
36    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
37    let backend = CrosstermBackend::new(stdout);
38    let mut terminal = Terminal::new(backend)?;
39
40    // Create app state
41    let mut app = TuiApp::new(db_path, config, db).await?;
42    let res = run_tui(&mut terminal, &mut app).await;
43
44    // Restore terminal
45    disable_raw_mode()?;
46    execute!(
47        terminal.backend_mut(),
48        LeaveAlternateScreen,
49        DisableMouseCapture
50    )?;
51    terminal.show_cursor()?;
52
53    if let Err(err) = res {
54        println!("{:?}", err)
55    }
56
57    Ok(())
58}
59
60// =============================================================================
61// Panel State Structures (Issue #251)
62// =============================================================================
63
64/// Panel visibility configuration - toggleable with F2/F3/F4 keys
65#[derive(Debug, Clone, Copy)]
66struct PanelVisibility {
67    tables: bool,  // F2 toggle - Tables browser panel
68    results: bool, // F3 toggle - Query results panel
69    history: bool, // F4 toggle - Query history panel
70}
71
72impl Default for PanelVisibility {
73    fn default() -> Self {
74        Self {
75            tables: true,
76            results: true,
77            history: true,
78        }
79    }
80}
81
82impl PanelVisibility {
83    /// Reset to default layout (F5)
84    fn reset(&mut self) {
85        *self = Self::default();
86    }
87}
88
89/// Active panel for keyboard focus navigation
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91enum FocusPanel {
92    Tables,
93    Results,
94    History,
95    Input,
96}
97
98impl FocusPanel {
99    /// Cycle to next visible panel (Tab key)
100    fn next(self, visibility: &PanelVisibility) -> Self {
101        let order = [
102            (FocusPanel::Tables, visibility.tables),
103            (FocusPanel::Results, visibility.results),
104            (FocusPanel::History, visibility.history),
105            (FocusPanel::Input, true), // Input always visible
106        ];
107
108        let current_idx = order.iter().position(|(p, _)| *p == self).unwrap_or(3);
109
110        // Find next visible panel
111        for i in 1..=order.len() {
112            let next_idx = (current_idx + i) % order.len();
113            if order[next_idx].1 {
114                return order[next_idx].0;
115            }
116        }
117        FocusPanel::Input // Fallback
118    }
119
120    /// Cycle to previous visible panel (Shift+Tab key)
121    fn prev(self, visibility: &PanelVisibility) -> Self {
122        let order = [
123            (FocusPanel::Tables, visibility.tables),
124            (FocusPanel::Results, visibility.results),
125            (FocusPanel::History, visibility.history),
126            (FocusPanel::Input, true),
127        ];
128
129        let current_idx = order.iter().position(|(p, _)| *p == self).unwrap_or(3);
130
131        for i in 1..=order.len() {
132            let prev_idx = (current_idx + order.len() - i) % order.len();
133            if order[prev_idx].1 {
134                return order[prev_idx].0;
135            }
136        }
137        FocusPanel::Input
138    }
139}
140
141/// Table entry in the tables browser
142#[derive(Debug, Clone)]
143struct TableEntry {
144    #[allow(dead_code)] // Reserved for keyspace display
145    keyspace: String,
146    #[allow(dead_code)] // Reserved for table name display
147    name: String,
148    qualified_name: String, // "keyspace.table"
149}
150
151/// Tables browser panel state
152#[derive(Debug)]
153struct TablesBrowserState {
154    entries: Vec<TableEntry>,
155    filtered_indices: Vec<usize>, // Indices into entries after filter
156    filter_text: String,
157    filter_active: bool, // Is filter input mode active
158    list_state: ListState,
159}
160
161impl Default for TablesBrowserState {
162    fn default() -> Self {
163        Self {
164            entries: Vec::new(),
165            filtered_indices: Vec::new(),
166            filter_text: String::new(),
167            filter_active: false,
168            list_state: ListState::default(),
169        }
170    }
171}
172
173impl TablesBrowserState {
174    /// Apply filter to entries and update filtered_indices
175    fn apply_filter(&mut self) {
176        if self.filter_text.is_empty() {
177            self.filtered_indices = (0..self.entries.len()).collect();
178        } else {
179            let filter_lower = self.filter_text.to_lowercase();
180            self.filtered_indices = self
181                .entries
182                .iter()
183                .enumerate()
184                .filter(|(_, e)| e.qualified_name.to_lowercase().contains(&filter_lower))
185                .map(|(i, _)| i)
186                .collect();
187        }
188        // Reset selection if out of bounds
189        if let Some(selected) = self.list_state.selected() {
190            if selected >= self.filtered_indices.len() {
191                if self.filtered_indices.is_empty() {
192                    self.list_state.select(None);
193                } else {
194                    self.list_state.select(Some(0));
195                }
196            }
197        }
198    }
199
200    /// Get currently selected entry
201    fn selected_entry(&self) -> Option<&TableEntry> {
202        self.list_state
203            .selected()
204            .and_then(|idx| self.filtered_indices.get(idx))
205            .and_then(|&entry_idx| self.entries.get(entry_idx))
206    }
207}
208
209/// Query results table state with scroll tracking
210#[derive(Debug)]
211struct ResultsTableState {
212    columns: Vec<String>,
213    rows: Vec<Vec<String>>,
214    row_offset: usize, // Vertical scroll position
215    col_offset: usize, // Horizontal scroll position (column index)
216    selected_row: Option<usize>,
217    column_widths: Vec<u16>, // Calculated widths for each column
218    table_state: TableState,
219}
220
221impl Default for ResultsTableState {
222    fn default() -> Self {
223        Self {
224            columns: Vec::new(),
225            rows: Vec::new(),
226            row_offset: 0,
227            col_offset: 0,
228            selected_row: None,
229            column_widths: Vec::new(),
230            table_state: TableState::default(),
231        }
232    }
233}
234
235impl ResultsTableState {
236    /// Calculate column widths based on content
237    fn calculate_widths(&mut self) {
238        if self.columns.is_empty() {
239            self.column_widths = vec![];
240            return;
241        }
242
243        // Start with header widths (minimum width)
244        let mut widths: Vec<u16> = self.columns.iter().map(|c| c.len() as u16).collect();
245
246        // Expand for content (sample first 100 rows)
247        for row in self.rows.iter().take(100) {
248            for (i, cell) in row.iter().enumerate() {
249                if i < widths.len() {
250                    widths[i] = widths[i].max(cell.len() as u16);
251                }
252            }
253        }
254
255        // Add padding (2 spaces) and cap at 40 chars
256        for w in &mut widths {
257            *w = (*w + 2).min(40);
258        }
259
260        self.column_widths = widths;
261    }
262
263    /// Get visible columns range based on offset and available width
264    fn visible_columns(&self, available_width: u16) -> std::ops::Range<usize> {
265        if self.column_widths.is_empty() {
266            return 0..0;
267        }
268
269        let start = self
270            .col_offset
271            .min(self.column_widths.len().saturating_sub(1));
272        let mut end = start;
273        let mut used_width = 0u16;
274
275        for i in start..self.column_widths.len() {
276            let col_width = self.column_widths.get(i).copied().unwrap_or(10);
277            if used_width + col_width > available_width && end > start {
278                break;
279            }
280            used_width += col_width;
281            end = i + 1;
282        }
283
284        start..end.max(start + 1).min(self.columns.len())
285    }
286
287    /// Check if there are more columns to the left
288    fn has_scroll_left(&self) -> bool {
289        self.col_offset > 0
290    }
291
292    /// Check if there are more columns to the right
293    fn has_scroll_right(&self, available_width: u16) -> bool {
294        let visible = self.visible_columns(available_width);
295        visible.end < self.columns.len()
296    }
297
298    /// Clear results
299    fn clear(&mut self) {
300        self.columns.clear();
301        self.rows.clear();
302        self.row_offset = 0;
303        self.col_offset = 0;
304        self.selected_row = None;
305        self.column_widths.clear();
306        self.table_state = TableState::default();
307    }
308}
309
310/// Layout areas computed from panel visibility
311struct LayoutAreas {
312    header: Rect,
313    tables: Option<Rect>,
314    results: Option<Rect>,
315    history: Option<Rect>,
316    input: Rect,
317    status: Rect,
318}
319
320// =============================================================================
321// TUI Application State
322// =============================================================================
323
324/// TUI Application State
325struct TuiApp {
326    db_path: std::path::PathBuf,
327    database: Arc<Database>,
328    input: String,
329    #[allow(dead_code)] // Legacy mode - replaced by focus_panel
330    input_mode: InputMode,
331    messages: Vec<String>,
332    #[allow(dead_code)] // Reserved for future scroll implementation
333    scroll_offset: usize,
334    history: Vec<String>,
335    history_index: Option<usize>,
336    query_results: Vec<QueryDisplayResult>,
337    #[allow(dead_code)] // Legacy - replaced by history_scroll
338    results_scroll: ListState,
339    show_help: bool,
340    status_message: String,
341    #[allow(dead_code)] // Reserved for future use
342    last_execution_time: Option<Duration>,
343    /// Status metrics for enhanced status bar (Issue #242)
344    status_metrics: Option<StatusMetrics>,
345    /// Last time metrics were refreshed
346    metrics_last_updated: Option<Instant>,
347
348    // Issue #251: Multi-panel layout fields
349    /// Panel visibility state (F2/F3/F4 toggles)
350    panel_visibility: PanelVisibility,
351    /// Currently focused panel for keyboard navigation
352    focus_panel: FocusPanel,
353    /// Tables browser panel state
354    tables_browser: TablesBrowserState,
355    /// Query results table with horizontal scrolling
356    results_table: ResultsTableState,
357    /// History panel scroll state
358    history_scroll: ListState,
359    /// Current keyspace context for header display
360    current_keyspace: Option<String>,
361}
362
363#[derive(Clone, PartialEq)]
364#[allow(dead_code)] // Normal variant reserved for future use
365enum InputMode {
366    Normal,
367    Editing,
368    Results,
369    Help,
370}
371
372#[derive(Clone)]
373struct QueryDisplayResult {
374    query: String,
375    success: bool,
376    #[allow(dead_code)] // Row count - used in History panel display
377    rows: usize,
378    execution_time: Option<Duration>,
379    #[allow(dead_code)] // Reserved for future error display
380    error_message: Option<String>,
381}
382
383impl TuiApp {
384    async fn new(db_path: &Path, config: &Config, database: Arc<Database>) -> Result<Self> {
385        // Collect initial metrics
386        let initial_metrics = StatusMetrics::collect(Some(db_path), Some(&database)).await;
387
388        // Create initial app state
389        let mut app = TuiApp {
390            db_path: db_path.to_path_buf(),
391            database,
392            input: String::new(),
393            input_mode: InputMode::Editing,
394            messages: vec![
395                "Welcome to CQLite TUI Mode!".to_string(),
396                "Type CQL queries and press Enter to execute.".to_string(),
397                "Press F1 for help, Tab to navigate panels, Esc to exit.".to_string(),
398                String::new(),
399            ],
400            scroll_offset: 0,
401            history: Vec::new(),
402            history_index: None,
403            query_results: Vec::new(),
404            results_scroll: ListState::default(),
405            show_help: false,
406            status_message: "Ready".to_string(),
407            last_execution_time: None,
408            status_metrics: Some(initial_metrics),
409            metrics_last_updated: Some(Instant::now()),
410            // Issue #251: Multi-panel layout initialization
411            panel_visibility: PanelVisibility::default(),
412            focus_panel: FocusPanel::Input,
413            tables_browser: TablesBrowserState::default(),
414            results_table: ResultsTableState::default(),
415            history_scroll: ListState::default(),
416            current_keyspace: None,
417        };
418
419        // Load tables for browser panel (Issue #251)
420        app.load_tables(config).await;
421
422        Ok(app)
423    }
424
425    /// Check if metrics need refresh (stale after METRICS_REFRESH_INTERVAL)
426    fn metrics_stale(&self) -> bool {
427        match self.metrics_last_updated {
428            Some(last) => last.elapsed() > METRICS_REFRESH_INTERVAL,
429            None => true,
430        }
431    }
432
433    /// Refresh status metrics if stale
434    async fn refresh_metrics(&mut self) {
435        if self.metrics_stale() {
436            self.status_metrics =
437                Some(StatusMetrics::collect(Some(&self.db_path), Some(&self.database)).await);
438            self.metrics_last_updated = Some(Instant::now());
439        }
440    }
441
442    /// Load tables into the tables browser (Issue #251)
443    ///
444    /// Uses the data directory scanning approach from REPL session fallback.
445    /// This scans the filesystem for table directories since we don't have
446    /// a ReplSession instance in TUI mode.
447    async fn load_tables(&mut self, config: &Config) {
448        // Get data directory from config
449        let data_dir = match &config.data_directory {
450            Some(dir) if !dir.as_os_str().is_empty() => dir,
451            _ => {
452                // No data directory configured - tables panel will be empty
453                return;
454            }
455        };
456
457        // Scan data directory for tables
458        match self.scan_tables(data_dir).await {
459            Ok(tables) => {
460                self.tables_browser.entries = tables;
461                self.tables_browser.apply_filter();
462
463                // Select first entry if available
464                if !self.tables_browser.filtered_indices.is_empty() {
465                    self.tables_browser.list_state.select(Some(0));
466                }
467            }
468            Err(e) => {
469                // Log error but don't fail initialization - empty table list is acceptable
470                eprintln!("Warning: Failed to load tables: {}", e);
471            }
472        }
473    }
474
475    /// Scan data directory for table entries
476    ///
477    /// This is adapted from ReplSession::scan_data_directory_tables but
478    /// returns TableEntry structs suitable for the TUI browser.
479    async fn scan_tables(&self, data_dir: &Path) -> Result<Vec<TableEntry>> {
480        use std::fs;
481
482        let mut entries = Vec::new();
483
484        // Scan all keyspace directories
485        let read_dir = fs::read_dir(data_dir)
486            .map_err(|e| anyhow::anyhow!("Failed to read data directory: {}", e))?;
487
488        for entry in read_dir {
489            let entry =
490                entry.map_err(|e| anyhow::anyhow!("Failed to read directory entry: {}", e))?;
491
492            if !entry.path().is_dir() {
493                continue;
494            }
495
496            let keyspace_name = match entry.file_name().to_str() {
497                Some(name) if !name.starts_with('.') && name != "system" => name.to_string(),
498                _ => continue,
499            };
500
501            // Scan tables in this keyspace
502            let keyspace_dir = entry.path();
503            let table_read_dir = match fs::read_dir(&keyspace_dir) {
504                Ok(rd) => rd,
505                Err(_) => continue, // Skip unreadable keyspace directories
506            };
507
508            for table_entry in table_read_dir {
509                let table_entry = match table_entry {
510                    Ok(e) => e,
511                    Err(_) => continue,
512                };
513
514                if !table_entry.path().is_dir() {
515                    continue;
516                }
517
518                if let Some(dir_name) = table_entry.file_name().to_str() {
519                    if let Some(table_name) = extract_table_name(dir_name) {
520                        entries.push(TableEntry {
521                            keyspace: keyspace_name.clone(),
522                            name: table_name.clone(),
523                            qualified_name: format!("{}.{}", keyspace_name, table_name),
524                        });
525                    }
526                }
527            }
528        }
529
530        // Sort by qualified name for consistent ordering
531        entries.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
532
533        Ok(entries)
534    }
535
536    /// Execute a CQL query
537    async fn execute_query(&mut self) {
538        if self.input.trim().is_empty() {
539            return;
540        }
541
542        let query = self.input.trim().to_string();
543        self.history.push(query.clone());
544        self.history_index = None;
545
546        self.status_message = "Executing query...".to_string();
547
548        let start_time = std::time::Instant::now();
549        match self.database.execute(&query).await {
550            Ok(result) => {
551                let execution_time = start_time.elapsed();
552                self.last_execution_time = Some(execution_time);
553
554                let display_result = QueryDisplayResult {
555                    query: query.clone(),
556                    success: true,
557                    rows: result.rows.len(),
558                    execution_time: Some(execution_time),
559                    error_message: None,
560                };
561
562                self.query_results.insert(0, display_result);
563
564                // Add result summary to messages
565                if result.rows.is_empty() && result.rows_affected > 0 {
566                    self.messages.push(format!(
567                        "✓ Query executed: {} rows affected ({})",
568                        result.rows_affected,
569                        format_duration(execution_time)
570                    ));
571                    // Clear results table for non-SELECT queries
572                    self.results_table.clear();
573                } else {
574                    self.messages.push(format!(
575                        "✓ Query executed: {} rows returned ({})",
576                        result.rows.len(),
577                        format_duration(execution_time)
578                    ));
579
580                    // Populate results table for display (Issue #251)
581                    if !result.rows.is_empty() {
582                        let column_names = result.rows[0].column_names();
583                        self.results_table.columns = column_names.clone();
584                        self.results_table.rows = result
585                            .rows
586                            .iter()
587                            .map(|row| {
588                                column_names
589                                    .iter()
590                                    .map(|col| {
591                                        row.get(col)
592                                            .map(|v| v.to_string())
593                                            .unwrap_or_else(|| "NULL".to_string())
594                                    })
595                                    .collect()
596                            })
597                            .collect();
598                        self.results_table.row_offset = 0;
599                        self.results_table.col_offset = 0;
600                        self.results_table.calculate_widths();
601
602                        // Also add to messages for scrollback
603                        self.messages
604                            .push(format!("Columns: {}", column_names.join(", ")));
605                    } else {
606                        self.results_table.clear();
607                    }
608                }
609
610                self.status_message =
611                    format!("Query completed in {}", format_duration(execution_time));
612            }
613            Err(e) => {
614                let execution_time = start_time.elapsed();
615
616                let display_result = QueryDisplayResult {
617                    query: query.clone(),
618                    success: false,
619                    rows: 0,
620                    execution_time: Some(execution_time),
621                    error_message: Some(e.to_string()),
622                };
623
624                self.query_results.insert(0, display_result);
625                self.messages.push(format!("✗ Query failed: {}", e));
626                self.status_message = "Query failed".to_string();
627                // Don't clear results on error - keep previous results visible
628            }
629        }
630
631        // Keep only last 20 results
632        if self.query_results.len() > 20 {
633            self.query_results.truncate(20);
634        }
635
636        // Keep only last 100 messages
637        if self.messages.len() > 100 {
638            self.messages.drain(0..self.messages.len() - 100);
639        }
640
641        self.input.clear();
642    }
643
644    /// Handle navigation in query history
645    fn navigate_history(&mut self, up: bool) {
646        if self.history.is_empty() {
647            return;
648        }
649
650        if up {
651            let index = match self.history_index {
652                None => self.history.len() - 1,
653                Some(i) if i > 0 => i - 1,
654                Some(_) => return,
655            };
656            self.history_index = Some(index);
657            self.input = self.history[index].clone();
658        } else {
659            match self.history_index {
660                None => return,
661                Some(i) if i < self.history.len() - 1 => {
662                    self.history_index = Some(i + 1);
663                    self.input = self.history[i + 1].clone();
664                }
665                Some(_) => {
666                    self.history_index = None;
667                    self.input.clear();
668                }
669            }
670        }
671    }
672}
673
674/// Main TUI event loop
675async fn run_tui<B: Backend>(terminal: &mut Terminal<B>, app: &mut TuiApp) -> Result<()> {
676    loop {
677        // Refresh metrics if stale (every 5 seconds)
678        app.refresh_metrics().await;
679
680        terminal.draw(|f| ui(f, app))?;
681
682        if event::poll(Duration::from_millis(100))? {
683            if let Event::Key(key) = event::read()? {
684                // Handle key events
685                if handle_key_event(app, key).await {
686                    return Ok(()); // Exit requested
687                }
688            }
689        }
690    }
691}
692
693/// Handle key events - returns true if should exit
694async fn handle_key_event(app: &mut TuiApp, key: event::KeyEvent) -> bool {
695    // Help mode - any key closes it
696    if app.show_help {
697        app.show_help = false;
698        return false;
699    }
700
701    // Filter input mode in tables panel - handle specially
702    if app.tables_browser.filter_active {
703        return handle_filter_key(app, key);
704    }
705
706    // Global keybindings (always active)
707    match key.code {
708        KeyCode::F(1) => {
709            app.show_help = true;
710            return false;
711        }
712        KeyCode::F(2) => {
713            app.panel_visibility.tables = !app.panel_visibility.tables;
714            // Adjust focus if hiding current panel
715            if !app.panel_visibility.tables && app.focus_panel == FocusPanel::Tables {
716                app.focus_panel = app.focus_panel.next(&app.panel_visibility);
717            }
718            return false;
719        }
720        KeyCode::F(3) => {
721            app.panel_visibility.results = !app.panel_visibility.results;
722            if !app.panel_visibility.results && app.focus_panel == FocusPanel::Results {
723                app.focus_panel = app.focus_panel.next(&app.panel_visibility);
724            }
725            return false;
726        }
727        KeyCode::F(4) => {
728            app.panel_visibility.history = !app.panel_visibility.history;
729            if !app.panel_visibility.history && app.focus_panel == FocusPanel::History {
730                app.focus_panel = app.focus_panel.next(&app.panel_visibility);
731            }
732            return false;
733        }
734        KeyCode::F(5) => {
735            app.panel_visibility.reset();
736            return false;
737        }
738        KeyCode::Esc => {
739            return true; // Exit
740        }
741        KeyCode::Tab => {
742            app.focus_panel = app.focus_panel.next(&app.panel_visibility);
743            return false;
744        }
745        KeyCode::BackTab => {
746            app.focus_panel = app.focus_panel.prev(&app.panel_visibility);
747            return false;
748        }
749        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
750            return true; // Ctrl+C exits
751        }
752        KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
753            app.messages.clear();
754            app.query_results.clear();
755            app.results_table.clear();
756            app.status_message = "Screen cleared".to_string();
757            return false;
758        }
759        // Number keys for direct panel focus (only when NOT in Input panel)
760        KeyCode::Char('1')
761            if key.modifiers.is_empty()
762                && app.panel_visibility.tables
763                && app.focus_panel != FocusPanel::Input =>
764        {
765            app.focus_panel = FocusPanel::Tables;
766            return false;
767        }
768        KeyCode::Char('2')
769            if key.modifiers.is_empty()
770                && app.panel_visibility.results
771                && app.focus_panel != FocusPanel::Input =>
772        {
773            app.focus_panel = FocusPanel::Results;
774            return false;
775        }
776        KeyCode::Char('3')
777            if key.modifiers.is_empty()
778                && app.panel_visibility.history
779                && app.focus_panel != FocusPanel::Input =>
780        {
781            app.focus_panel = FocusPanel::History;
782            return false;
783        }
784        _ => {}
785    }
786
787    // Panel-specific keybindings
788    match app.focus_panel {
789        FocusPanel::Tables => handle_tables_key(app, key).await,
790        FocusPanel::Results => handle_results_key(app, key),
791        FocusPanel::History => handle_history_key(app, key),
792        FocusPanel::Input => handle_input_key(app, key).await,
793    }
794
795    false
796}
797
798/// Handle keys when filter input is active
799fn handle_filter_key(app: &mut TuiApp, key: event::KeyEvent) -> bool {
800    match key.code {
801        KeyCode::Enter | KeyCode::Esc => {
802            app.tables_browser.filter_active = false;
803        }
804        KeyCode::Char(c) => {
805            app.tables_browser.filter_text.push(c);
806            app.tables_browser.apply_filter();
807        }
808        KeyCode::Backspace => {
809            app.tables_browser.filter_text.pop();
810            app.tables_browser.apply_filter();
811        }
812        _ => {}
813    }
814    false
815}
816
817/// Handle keys in Tables panel
818async fn handle_tables_key(app: &mut TuiApp, key: event::KeyEvent) {
819    let browser = &mut app.tables_browser;
820
821    match key.code {
822        KeyCode::Char('j') | KeyCode::Down => {
823            let selected = browser.list_state.selected().unwrap_or(0);
824            if selected < browser.filtered_indices.len().saturating_sub(1) {
825                browser.list_state.select(Some(selected + 1));
826            }
827        }
828        KeyCode::Char('k') | KeyCode::Up => {
829            let selected = browser.list_state.selected().unwrap_or(0);
830            if selected > 0 {
831                browser.list_state.select(Some(selected - 1));
832            }
833        }
834        KeyCode::Char('/') => {
835            browser.filter_active = true;
836        }
837        KeyCode::Enter => {
838            // Query selected table
839            if let Some(entry) = browser.selected_entry().cloned() {
840                app.input = format!("SELECT * FROM {} LIMIT 100", entry.qualified_name);
841                app.focus_panel = FocusPanel::Input;
842            }
843        }
844        KeyCode::Char('d') => {
845            // Describe selected table
846            if let Some(entry) = browser.selected_entry().cloned() {
847                app.input = format!("DESCRIBE {}", entry.qualified_name);
848                app.focus_panel = FocusPanel::Input;
849            }
850        }
851        KeyCode::Char('g') => {
852            browser.list_state.select(Some(0));
853        }
854        KeyCode::Char('G') => {
855            let last = browser.filtered_indices.len().saturating_sub(1);
856            browser.list_state.select(Some(last));
857        }
858        _ => {}
859    }
860}
861
862/// Handle keys in Results panel
863fn handle_results_key(app: &mut TuiApp, key: event::KeyEvent) {
864    let results = &mut app.results_table;
865
866    match key.code {
867        KeyCode::Char('j') | KeyCode::Down => {
868            if results.row_offset < results.rows.len().saturating_sub(1) {
869                results.row_offset += 1;
870            }
871        }
872        KeyCode::Char('k') | KeyCode::Up => {
873            if results.row_offset > 0 {
874                results.row_offset -= 1;
875            }
876        }
877        KeyCode::Char('h') | KeyCode::Left => {
878            if results.col_offset > 0 {
879                results.col_offset -= 1;
880            }
881        }
882        KeyCode::Char('l') | KeyCode::Right => {
883            if results.col_offset < results.columns.len().saturating_sub(1) {
884                results.col_offset += 1;
885            }
886        }
887        KeyCode::Char('g') => {
888            results.row_offset = 0;
889            results.col_offset = 0;
890        }
891        KeyCode::Char('G') => {
892            results.row_offset = results.rows.len().saturating_sub(10);
893        }
894        KeyCode::PageUp => {
895            results.row_offset = results.row_offset.saturating_sub(20);
896        }
897        KeyCode::PageDown => {
898            let max_offset = results.rows.len().saturating_sub(10);
899            results.row_offset = (results.row_offset + 20).min(max_offset);
900        }
901        _ => {}
902    }
903}
904
905/// Handle keys in History panel
906fn handle_history_key(app: &mut TuiApp, key: event::KeyEvent) {
907    match key.code {
908        KeyCode::Char('j') | KeyCode::Down => {
909            let selected = app.history_scroll.selected().unwrap_or(0);
910            if selected < app.query_results.len().saturating_sub(1) {
911                app.history_scroll.select(Some(selected + 1));
912            }
913        }
914        KeyCode::Char('k') | KeyCode::Up => {
915            let selected = app.history_scroll.selected().unwrap_or(0);
916            if selected > 0 {
917                app.history_scroll.select(Some(selected - 1));
918            }
919        }
920        KeyCode::Enter => {
921            // Copy selected query to input
922            if let Some(selected) = app.history_scroll.selected() {
923                if let Some(result) = app.query_results.get(selected) {
924                    app.input = result.query.clone();
925                    app.focus_panel = FocusPanel::Input;
926                }
927            }
928        }
929        KeyCode::Char('g') => {
930            app.history_scroll.select(Some(0));
931        }
932        KeyCode::Char('G') => {
933            let last = app.query_results.len().saturating_sub(1);
934            app.history_scroll.select(Some(last));
935        }
936        _ => {}
937    }
938}
939
940/// Handle keys in Input panel
941async fn handle_input_key(app: &mut TuiApp, key: event::KeyEvent) {
942    match key.code {
943        KeyCode::Enter => {
944            app.execute_query().await;
945        }
946        KeyCode::Char(c) => {
947            app.input.push(c);
948            // Reset history navigation when user starts typing
949            app.history_index = None;
950        }
951        KeyCode::Backspace => {
952            app.input.pop();
953            // Reset history navigation when user edits input
954            app.history_index = None;
955        }
956        KeyCode::Up => {
957            app.navigate_history(true);
958        }
959        KeyCode::Down => {
960            app.navigate_history(false);
961        }
962        _ => {}
963    }
964}
965
966// =============================================================================
967// Layout Calculation (Issue #251)
968// =============================================================================
969
970/// Build dynamic layout based on panel visibility
971fn build_layout(area: Rect, visibility: &PanelVisibility) -> LayoutAreas {
972    // Vertical layout: Header | Main | Input | Status
973    let vertical_chunks = Layout::default()
974        .direction(Direction::Vertical)
975        .margin(0)
976        .constraints([
977            Constraint::Length(3), // Header
978            Constraint::Min(10),   // Main content
979            Constraint::Length(3), // Input
980            Constraint::Length(3), // Status
981        ])
982        .split(area);
983
984    let main_area = vertical_chunks[1];
985
986    // Horizontal split: Tables panel (left) | Right side
987    let (tables_area, right_area) = if visibility.tables {
988        let h_chunks = Layout::default()
989            .direction(Direction::Horizontal)
990            .constraints([
991                Constraint::Percentage(25), // Tables panel
992                Constraint::Percentage(75), // Right side
993            ])
994            .split(main_area);
995        (Some(h_chunks[0]), h_chunks[1])
996    } else {
997        (None, main_area)
998    };
999
1000    // Right side vertical split: Results (top) | History (bottom)
1001    let (results_area, history_area) = match (visibility.results, visibility.history) {
1002        (true, true) => {
1003            let v_chunks = Layout::default()
1004                .direction(Direction::Vertical)
1005                .constraints([
1006                    Constraint::Percentage(65), // Results
1007                    Constraint::Percentage(35), // History
1008                ])
1009                .split(right_area);
1010            (Some(v_chunks[0]), Some(v_chunks[1]))
1011        }
1012        (true, false) => (Some(right_area), None),
1013        (false, true) => (None, Some(right_area)),
1014        (false, false) => (None, None),
1015    };
1016
1017    LayoutAreas {
1018        header: vertical_chunks[0],
1019        tables: tables_area,
1020        results: results_area,
1021        history: history_area,
1022        input: vertical_chunks[2],
1023        status: vertical_chunks[3],
1024    }
1025}
1026
1027// =============================================================================
1028// Panel Rendering Functions (Issue #251)
1029// =============================================================================
1030
1031/// Render the Tables browser panel
1032fn render_tables_panel(f: &mut Frame, area: Rect, app: &mut TuiApp) {
1033    let is_focused = app.focus_panel == FocusPanel::Tables;
1034    let border_style = if is_focused {
1035        Style::default().fg(Color::Cyan)
1036    } else {
1037        Style::default().fg(Color::DarkGray)
1038    };
1039
1040    // Split for filter input (if active or has text)
1041    let (filter_area, list_area) =
1042        if app.tables_browser.filter_active || !app.tables_browser.filter_text.is_empty() {
1043            let chunks = Layout::default()
1044                .direction(Direction::Vertical)
1045                .constraints([Constraint::Length(3), Constraint::Min(1)])
1046                .split(area);
1047            (Some(chunks[0]), chunks[1])
1048        } else {
1049            (None, area)
1050        };
1051
1052    // Render filter input if visible
1053    if let Some(filter_rect) = filter_area {
1054        let filter_border = if app.tables_browser.filter_active {
1055            Style::default().fg(Color::Yellow)
1056        } else {
1057            Style::default().fg(Color::DarkGray)
1058        };
1059        let filter = Paragraph::new(app.tables_browser.filter_text.as_str())
1060            .block(
1061                Block::default()
1062                    .borders(Borders::ALL)
1063                    .title("Filter (/)")
1064                    .border_style(filter_border),
1065            )
1066            .style(if app.tables_browser.filter_active {
1067                Style::default().fg(Color::Yellow)
1068            } else {
1069                Style::default()
1070            });
1071        f.render_widget(filter, filter_rect);
1072
1073        // Set cursor in filter input mode
1074        if app.tables_browser.filter_active {
1075            f.set_cursor(
1076                filter_rect.x + app.tables_browser.filter_text.len() as u16 + 1,
1077                filter_rect.y + 1,
1078            );
1079        }
1080    }
1081
1082    // Render table list - collect items before borrowing list_state mutably
1083    let items: Vec<ListItem> = app
1084        .tables_browser
1085        .filtered_indices
1086        .iter()
1087        .map(|&idx| {
1088            if let Some(entry) = app.tables_browser.entries.get(idx) {
1089                ListItem::new(Line::from(vec![
1090                    Span::styled("+ ", Style::default().fg(Color::Green)),
1091                    Span::raw(entry.qualified_name.clone()),
1092                ]))
1093            } else {
1094                ListItem::new(Line::from(""))
1095            }
1096        })
1097        .collect();
1098
1099    let title = format!(
1100        "Tables [1] ({}/{})",
1101        app.tables_browser.filtered_indices.len(),
1102        app.tables_browser.entries.len()
1103    );
1104
1105    let list = List::new(items)
1106        .block(
1107            Block::default()
1108                .borders(Borders::ALL)
1109                .title(title)
1110                .border_style(border_style),
1111        )
1112        .highlight_style(
1113            Style::default()
1114                .add_modifier(Modifier::REVERSED)
1115                .fg(Color::Cyan),
1116        )
1117        .highlight_symbol("> ");
1118
1119    f.render_stateful_widget(list, list_area, &mut app.tables_browser.list_state);
1120}
1121
1122/// Render the Query Results panel with Table widget
1123fn render_results_panel(f: &mut Frame, area: Rect, app: &mut TuiApp) {
1124    let is_focused = app.focus_panel == FocusPanel::Results;
1125    let border_style = if is_focused {
1126        Style::default().fg(Color::Cyan)
1127    } else {
1128        Style::default().fg(Color::DarkGray)
1129    };
1130
1131    // If no results, show empty state with messages
1132    if app.results_table.columns.is_empty() {
1133        // Show messages instead when no query results
1134        let messages: Vec<ListItem> = app
1135            .messages
1136            .iter()
1137            .enumerate()
1138            .map(|(i, m)| {
1139                let content = Line::from(Span::raw(format!("{}: {}", i + 1, m)));
1140                ListItem::new(content)
1141            })
1142            .collect();
1143
1144        let empty_widget = List::new(messages).block(
1145            Block::default()
1146                .borders(Borders::ALL)
1147                .title("Query Results [2]")
1148                .border_style(border_style),
1149        );
1150        f.render_widget(empty_widget, area);
1151        return;
1152    }
1153
1154    // Calculate visible columns based on available width
1155    let inner_width = area.width.saturating_sub(4); // Account for borders and highlight symbol
1156    let visible_cols = app.results_table.visible_columns(inner_width);
1157
1158    // Build column widths for visible columns - clone to avoid borrow issues
1159    let column_widths: Vec<u16> = app.results_table.column_widths.clone();
1160
1161    // Build header row - clone columns to avoid borrow issues and truncate to column width
1162    let header_cells: Vec<Cell> = app.results_table.columns[visible_cols.clone()]
1163        .iter()
1164        .enumerate()
1165        .map(|(idx, h)| {
1166            // Get column index in full list for width lookup
1167            let col_idx = visible_cols.start + idx;
1168            let col_width = column_widths.get(col_idx).copied().unwrap_or(10) as usize;
1169            let max_chars = col_width.saturating_sub(2);
1170
1171            // Truncate header to fit column width
1172            let truncated = if h.len() > max_chars {
1173                format!("{}…", &h[..max_chars.saturating_sub(1)])
1174            } else {
1175                h.clone()
1176            };
1177
1178            Cell::from(truncated).style(
1179                Style::default()
1180                    .fg(Color::Yellow)
1181                    .add_modifier(Modifier::BOLD),
1182            )
1183        })
1184        .collect();
1185    let header = Row::new(header_cells).height(1);
1186
1187    // Build data rows with vertical scrolling - clone row data to avoid borrow issues
1188    let visible_height = area.height.saturating_sub(4) as usize; // Account for borders and header
1189    let row_offset = app.results_table.row_offset;
1190    let rows: Vec<Row> = app
1191        .results_table
1192        .rows
1193        .iter()
1194        .skip(row_offset)
1195        .take(visible_height)
1196        .map(|row| {
1197            let cells: Vec<Cell> = visible_cols
1198                .clone()
1199                .enumerate()
1200                .filter_map(|(_idx, i)| {
1201                    row.get(i).map(|cell_content| {
1202                        // Get the column width for truncation
1203                        let col_width = column_widths.get(i).copied().unwrap_or(10) as usize;
1204                        // Truncate cell content to fit column width (account for padding)
1205                        let max_chars = col_width.saturating_sub(2);
1206                        let truncated = if cell_content.len() > max_chars {
1207                            format!("{}…", &cell_content[..max_chars.saturating_sub(1)])
1208                        } else {
1209                            cell_content.clone()
1210                        };
1211                        Cell::from(truncated)
1212                    })
1213                })
1214                .collect();
1215            Row::new(cells)
1216        })
1217        .collect();
1218    let widths: Vec<Constraint> = visible_cols
1219        .clone()
1220        .filter_map(|i| column_widths.get(i).map(|&w| Constraint::Length(w)))
1221        .collect();
1222
1223    // Build title with scroll indicators
1224    let has_left = app.results_table.has_scroll_left();
1225    let has_right = app.results_table.has_scroll_right(inner_width);
1226    let num_cols = app.results_table.columns.len();
1227    let num_rows = app.results_table.rows.len();
1228    let scroll_hint = if has_left || has_right {
1229        format!(
1230            " (cols {}-{}/{}) ",
1231            visible_cols.start + 1,
1232            visible_cols.end,
1233            num_cols
1234        )
1235    } else {
1236        String::new()
1237    };
1238    let row_hint = if num_rows > visible_height {
1239        format!(
1240            " rows {}-{}/{}",
1241            row_offset + 1,
1242            (row_offset + visible_height).min(num_rows),
1243            num_rows
1244        )
1245    } else {
1246        format!(" {} rows", num_rows)
1247    };
1248    let title = format!("Query Results [2]{}{}", scroll_hint, row_hint);
1249
1250    let table = Table::new(rows)
1251        .header(header)
1252        .block(
1253            Block::default()
1254                .borders(Borders::ALL)
1255                .title(title)
1256                .border_style(border_style),
1257        )
1258        .widths(&widths)
1259        .column_spacing(1) // Add 1 space between columns to prevent overlap
1260        .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
1261        .highlight_symbol(">> ");
1262
1263    f.render_stateful_widget(table, area, &mut app.results_table.table_state);
1264}
1265
1266/// Render the Query History panel
1267fn render_history_panel(f: &mut Frame, area: Rect, app: &mut TuiApp) {
1268    let is_focused = app.focus_panel == FocusPanel::History;
1269    let border_style = if is_focused {
1270        Style::default().fg(Color::Cyan)
1271    } else {
1272        Style::default().fg(Color::DarkGray)
1273    };
1274
1275    let items: Vec<ListItem> = app
1276        .query_results
1277        .iter()
1278        .map(|result| {
1279            let status = if result.success { "✓" } else { "✗" };
1280            let time_str = result
1281                .execution_time
1282                .map(|t| format_duration(t))
1283                .unwrap_or_else(|| "--".to_string());
1284
1285            let status_style = if result.success {
1286                Style::default().fg(Color::Green)
1287            } else {
1288                Style::default().fg(Color::Red)
1289            };
1290
1291            // Truncate query to fit available width (estimate ~60 chars for query text)
1292            let query_text = if result.query.len() > 60 {
1293                format!("{}…", &result.query[..59])
1294            } else {
1295                result.query.clone()
1296            };
1297
1298            // CRITICAL: Build the entire line content as a single Line to prevent wrapping
1299            // Format: "✓ 7ms SELECT * FROM test_basic.composite_key_table"
1300            let line = Line::from(vec![
1301                Span::styled(status, status_style),
1302                Span::raw(" "),
1303                Span::styled(time_str, Style::default().fg(Color::Cyan)),
1304                Span::raw(" "),
1305                Span::raw(query_text),
1306            ]);
1307
1308            ListItem::new(line)
1309        })
1310        .collect();
1311
1312    let title = format!("Query History [3] ({})", app.query_results.len());
1313
1314    let list = List::new(items)
1315        .block(
1316            Block::default()
1317                .borders(Borders::ALL)
1318                .title(title)
1319                .border_style(border_style),
1320        )
1321        .highlight_style(
1322            Style::default()
1323                .add_modifier(Modifier::BOLD)
1324                .bg(Color::DarkGray),
1325        )
1326        .highlight_symbol("> ");
1327
1328    f.render_stateful_widget(list, area, &mut app.history_scroll);
1329}
1330
1331/// Render the header bar
1332fn render_header(f: &mut Frame, area: Rect, app: &TuiApp) {
1333    let keyspace_text = app
1334        .current_keyspace
1335        .as_ref()
1336        .map(|ks| format!("[{}]", ks))
1337        .unwrap_or_else(|| "[no keyspace]".to_string());
1338
1339    let header = Paragraph::new(vec![
1340        Line::from(vec![
1341            Span::styled(
1342                "CQLite TUI v0.1.0",
1343                Style::default()
1344                    .fg(Color::Cyan)
1345                    .add_modifier(Modifier::BOLD),
1346            ),
1347            Span::raw("  "),
1348            Span::styled(keyspace_text, Style::default().fg(Color::Yellow)),
1349            Span::raw("  "),
1350            Span::styled("F1:Help", Style::default().fg(Color::DarkGray)),
1351            Span::raw(" "),
1352            Span::styled("F2-F4:Toggle", Style::default().fg(Color::DarkGray)),
1353            Span::raw(" "),
1354            Span::styled("Esc:Exit", Style::default().fg(Color::DarkGray)),
1355        ]),
1356        Line::from(vec![
1357            Span::raw("Database: "),
1358            Span::styled(
1359                app.db_path.display().to_string(),
1360                Style::default().fg(Color::Green),
1361            ),
1362        ]),
1363    ])
1364    .block(Block::default().borders(Borders::ALL));
1365    f.render_widget(header, area);
1366}
1367
1368/// Render the input area
1369fn render_input(f: &mut Frame, area: Rect, app: &TuiApp) {
1370    let is_focused = app.focus_panel == FocusPanel::Input;
1371    let border_style = if is_focused {
1372        Style::default().fg(Color::Cyan)
1373    } else {
1374        Style::default().fg(Color::DarkGray)
1375    };
1376
1377    let input = Paragraph::new(app.input.as_str())
1378        .style(if is_focused {
1379            Style::default().fg(Color::Yellow)
1380        } else {
1381            Style::default()
1382        })
1383        .block(
1384            Block::default()
1385                .borders(Borders::ALL)
1386                .title("CQL> ")
1387                .border_style(border_style),
1388        );
1389    f.render_widget(input, area);
1390
1391    // Set cursor position when input is focused
1392    if is_focused && !app.tables_browser.filter_active {
1393        f.set_cursor(area.x + app.input.len() as u16 + 1, area.y + 1);
1394    }
1395}
1396
1397/// Render the status bar
1398fn render_status(f: &mut Frame, area: Rect, app: &TuiApp) {
1399    let (health_text, health_color) = match app.status_metrics.as_ref() {
1400        Some(metrics) => match metrics.health {
1401            HealthIndicator::Ok => ("OK", Color::Green),
1402            HealthIndicator::Warning => ("WARN", Color::Yellow),
1403            HealthIndicator::Error => ("ERR", Color::Red),
1404        },
1405        None => ("--", Color::DarkGray),
1406    };
1407
1408    let memory_text = app
1409        .status_metrics
1410        .as_ref()
1411        .map(|m| m.format_memory())
1412        .unwrap_or_else(|| "--".to_string());
1413
1414    let data_text = app
1415        .status_metrics
1416        .as_ref()
1417        .map(|m| m.format_data())
1418        .unwrap_or_else(|| "--".to_string());
1419
1420    // Show focused panel in mode
1421    let mode_text = match app.focus_panel {
1422        FocusPanel::Tables => "TABLES",
1423        FocusPanel::Results => "RESULTS",
1424        FocusPanel::History => "HISTORY",
1425        FocusPanel::Input => "INPUT",
1426    };
1427
1428    let status_line = Line::from(vec![
1429        Span::raw("Health: "),
1430        Span::styled(health_text, Style::default().fg(health_color)),
1431        Span::raw(" | Mem: "),
1432        Span::styled(&memory_text, Style::default().fg(Color::Cyan)),
1433        Span::raw(" | Data: "),
1434        Span::styled(&data_text, Style::default().fg(Color::Cyan)),
1435        Span::raw(" | Status: "),
1436        Span::styled(&app.status_message, Style::default().fg(Color::Green)),
1437        Span::raw(" | Mode: "),
1438        Span::styled(mode_text, Style::default().fg(Color::Cyan)),
1439    ]);
1440
1441    let status = Paragraph::new(status_line).block(Block::default().borders(Borders::ALL));
1442    f.render_widget(status, area);
1443}
1444
1445/// Draw the TUI interface
1446fn ui(f: &mut Frame, app: &mut TuiApp) {
1447    if app.show_help {
1448        draw_help(f);
1449        return;
1450    }
1451
1452    // Build dynamic layout based on panel visibility
1453    let layout = build_layout(f.size(), &app.panel_visibility);
1454
1455    // Render header
1456    render_header(f, layout.header, app);
1457
1458    // Render visible panels
1459    if let Some(tables_area) = layout.tables {
1460        render_tables_panel(f, tables_area, app);
1461    }
1462
1463    if let Some(results_area) = layout.results {
1464        render_results_panel(f, results_area, app);
1465    }
1466
1467    if let Some(history_area) = layout.history {
1468        render_history_panel(f, history_area, app);
1469    }
1470
1471    // If no panels are visible in main area, show a message
1472    if layout.tables.is_none() && layout.results.is_none() && layout.history.is_none() {
1473        // This shouldn't happen normally, but handle gracefully
1474        let main_area = Layout::default()
1475            .direction(Direction::Vertical)
1476            .constraints([
1477                Constraint::Length(3),
1478                Constraint::Min(1),
1479                Constraint::Length(3),
1480                Constraint::Length(3),
1481            ])
1482            .split(f.size())[1];
1483
1484        let msg = Paragraph::new("Press F2/F3/F4 to show panels, or F5 to reset layout")
1485            .block(Block::default().borders(Borders::ALL).title("No Panels"))
1486            .style(Style::default().fg(Color::DarkGray));
1487        f.render_widget(msg, main_area);
1488    }
1489
1490    // Render input area
1491    render_input(f, layout.input, app);
1492
1493    // Render status bar
1494    render_status(f, layout.status, app);
1495}
1496
1497/// Draw the help screen
1498fn draw_help(f: &mut Frame) {
1499    let help_text = vec![
1500        Line::from(Span::styled(
1501            "CQLite TUI Help (Issue #251)",
1502            Style::default()
1503                .fg(Color::Cyan)
1504                .add_modifier(Modifier::BOLD),
1505        )),
1506        Line::from(""),
1507        Line::from(Span::styled(
1508            "Global Commands:",
1509            Style::default().add_modifier(Modifier::BOLD),
1510        )),
1511        Line::from("  F1          Toggle this help screen"),
1512        Line::from("  F2          Toggle Tables panel"),
1513        Line::from("  F3          Toggle Results panel"),
1514        Line::from("  F4          Toggle History panel"),
1515        Line::from("  F5          Reset layout (show all panels)"),
1516        Line::from("  Tab         Cycle focus to next panel"),
1517        Line::from("  Shift+Tab   Cycle focus to previous panel"),
1518        Line::from("  1/2/3       Jump directly to panel"),
1519        Line::from("  Esc         Exit application"),
1520        Line::from("  Ctrl+C      Quit immediately"),
1521        Line::from("  Ctrl+L      Clear screen and history"),
1522        Line::from(""),
1523        Line::from(Span::styled(
1524            "Tables Panel [1]:",
1525            Style::default().add_modifier(Modifier::BOLD),
1526        )),
1527        Line::from("  j/k, Up/Down  Navigate tables"),
1528        Line::from("  /             Open filter input"),
1529        Line::from("  Enter         Query selected table"),
1530        Line::from("  d             Describe selected table"),
1531        Line::from("  g/G           Jump to first/last table"),
1532        Line::from(""),
1533        Line::from(Span::styled(
1534            "Results Panel [2]:",
1535            Style::default().add_modifier(Modifier::BOLD),
1536        )),
1537        Line::from("  j/k, Up/Down  Scroll rows"),
1538        Line::from("  h/l, Left/Right  Scroll columns (horizontal)"),
1539        Line::from("  g/G           Jump to first/last row"),
1540        Line::from("  PgUp/PgDn     Page up/down"),
1541        Line::from(""),
1542        Line::from(Span::styled(
1543            "History Panel [3]:",
1544            Style::default().add_modifier(Modifier::BOLD),
1545        )),
1546        Line::from("  j/k, Up/Down  Navigate history"),
1547        Line::from("  Enter         Copy query to input"),
1548        Line::from("  g/G           Jump to first/last entry"),
1549        Line::from(""),
1550        Line::from(Span::styled(
1551            "Input Panel:",
1552            Style::default().add_modifier(Modifier::BOLD),
1553        )),
1554        Line::from("  Enter         Execute current query"),
1555        Line::from("  Up/Down       Navigate command history"),
1556        Line::from("  Backspace     Delete character"),
1557        Line::from(""),
1558        Line::from(Span::styled(
1559            "Press any key to close this help",
1560            Style::default().fg(Color::DarkGray),
1561        )),
1562    ];
1563
1564    let help_paragraph = Paragraph::new(help_text)
1565        .block(
1566            Block::default()
1567                .borders(Borders::ALL)
1568                .title("Help - Press any key to close")
1569                .border_style(Style::default().fg(Color::Yellow)),
1570        )
1571        .wrap(Wrap { trim: true });
1572
1573    let area = centered_rect(85, 95, f.size());
1574    f.render_widget(help_paragraph, area);
1575}
1576
1577/// Create a centered rectangle
1578fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1579    let popup_layout = Layout::default()
1580        .direction(Direction::Vertical)
1581        .constraints([
1582            Constraint::Percentage((100 - percent_y) / 2),
1583            Constraint::Percentage(percent_y),
1584            Constraint::Percentage((100 - percent_y) / 2),
1585        ])
1586        .split(r);
1587
1588    Layout::default()
1589        .direction(Direction::Horizontal)
1590        .constraints([
1591            Constraint::Percentage((100 - percent_x) / 2),
1592            Constraint::Percentage(percent_x),
1593            Constraint::Percentage((100 - percent_x) / 2),
1594        ])
1595        .split(popup_layout[1])[1]
1596}
1597
1598/// Format a Duration for display with smart unit selection
1599///
1600/// - < 1ms: Display as "XXXμs" (microseconds)
1601/// - 1-999ms: Display as "X.Xms" (milliseconds with 1 decimal)
1602/// - >= 1000ms: Display as "X.Xs" (seconds with 1 decimal)
1603///
1604/// Examples:
1605/// - 450μs -> "450μs"
1606/// - 1.2ms -> "1.2ms"
1607/// - 7.0ms -> "7.0ms"
1608/// - 1500ms -> "1.5s"
1609fn format_duration(duration: Duration) -> String {
1610    let micros = duration.as_micros();
1611
1612    if micros < 1_000 {
1613        // Sub-millisecond: show microseconds
1614        format!("{}μs", micros)
1615    } else if micros < 1_000_000 {
1616        // 1-999ms: show milliseconds with 1 decimal place
1617        format!("{:.1}ms", micros as f64 / 1_000.0)
1618    } else {
1619        // >= 1 second: show seconds with 1 decimal place
1620        format!("{:.1}s", micros as f64 / 1_000_000.0)
1621    }
1622}
1623
1624/// Extract table name from SSTable directory name
1625///
1626/// Expected format: tablename-uuid
1627/// Returns the table name part before the first dash.
1628fn extract_table_name(dir_name: &str) -> Option<String> {
1629    if let Some(dash_pos) = dir_name.find('-') {
1630        let table_part = &dir_name[..dash_pos];
1631        if !table_part.is_empty() && table_part.chars().all(|c| c.is_alphanumeric() || c == '_') {
1632            return Some(table_part.to_string());
1633        }
1634    }
1635    None
1636}
1637
1638// =============================================================================
1639// Unit Tests
1640// =============================================================================
1641
1642#[cfg(test)]
1643mod tests {
1644    use super::*;
1645
1646    // -------------------------------------------------------------------------
1647    // FocusPanel Tests
1648    // -------------------------------------------------------------------------
1649
1650    #[test]
1651    fn test_focus_panel_next_all_visible() {
1652        let visibility = PanelVisibility {
1653            tables: true,
1654            results: true,
1655            history: true,
1656        };
1657
1658        // Cycle through all panels
1659        assert_eq!(FocusPanel::Tables.next(&visibility), FocusPanel::Results);
1660        assert_eq!(FocusPanel::Results.next(&visibility), FocusPanel::History);
1661        assert_eq!(FocusPanel::History.next(&visibility), FocusPanel::Input);
1662        assert_eq!(FocusPanel::Input.next(&visibility), FocusPanel::Tables);
1663    }
1664
1665    #[test]
1666    fn test_focus_panel_prev_all_visible() {
1667        let visibility = PanelVisibility {
1668            tables: true,
1669            results: true,
1670            history: true,
1671        };
1672
1673        // Cycle backwards through all panels
1674        assert_eq!(FocusPanel::Tables.prev(&visibility), FocusPanel::Input);
1675        assert_eq!(FocusPanel::Input.prev(&visibility), FocusPanel::History);
1676        assert_eq!(FocusPanel::History.prev(&visibility), FocusPanel::Results);
1677        assert_eq!(FocusPanel::Results.prev(&visibility), FocusPanel::Tables);
1678    }
1679
1680    #[test]
1681    fn test_focus_panel_next_skips_hidden_tables() {
1682        let visibility = PanelVisibility {
1683            tables: false,
1684            results: true,
1685            history: true,
1686        };
1687
1688        // Should skip Tables and go to Results
1689        assert_eq!(FocusPanel::Input.next(&visibility), FocusPanel::Results);
1690        assert_eq!(FocusPanel::Results.next(&visibility), FocusPanel::History);
1691        assert_eq!(FocusPanel::History.next(&visibility), FocusPanel::Input);
1692    }
1693
1694    #[test]
1695    fn test_focus_panel_next_skips_hidden_results() {
1696        let visibility = PanelVisibility {
1697            tables: true,
1698            results: false,
1699            history: true,
1700        };
1701
1702        // Should skip Results
1703        assert_eq!(FocusPanel::Tables.next(&visibility), FocusPanel::History);
1704        assert_eq!(FocusPanel::History.next(&visibility), FocusPanel::Input);
1705        assert_eq!(FocusPanel::Input.next(&visibility), FocusPanel::Tables);
1706    }
1707
1708    #[test]
1709    fn test_focus_panel_next_skips_hidden_history() {
1710        let visibility = PanelVisibility {
1711            tables: true,
1712            results: true,
1713            history: false,
1714        };
1715
1716        // Should skip History
1717        assert_eq!(FocusPanel::Results.next(&visibility), FocusPanel::Input);
1718        assert_eq!(FocusPanel::Input.next(&visibility), FocusPanel::Tables);
1719        assert_eq!(FocusPanel::Tables.next(&visibility), FocusPanel::Results);
1720    }
1721
1722    #[test]
1723    fn test_focus_panel_next_only_input_visible() {
1724        let visibility = PanelVisibility {
1725            tables: false,
1726            results: false,
1727            history: false,
1728        };
1729
1730        // All panels hidden - should cycle only on Input
1731        assert_eq!(FocusPanel::Input.next(&visibility), FocusPanel::Input);
1732        assert_eq!(FocusPanel::Tables.next(&visibility), FocusPanel::Input);
1733        assert_eq!(FocusPanel::Results.next(&visibility), FocusPanel::Input);
1734        assert_eq!(FocusPanel::History.next(&visibility), FocusPanel::Input);
1735    }
1736
1737    #[test]
1738    fn test_focus_panel_prev_skips_hidden_panels() {
1739        let visibility = PanelVisibility {
1740            tables: false,
1741            results: true,
1742            history: false,
1743        };
1744
1745        // Only Results and Input visible
1746        assert_eq!(FocusPanel::Results.prev(&visibility), FocusPanel::Input);
1747        assert_eq!(FocusPanel::Input.prev(&visibility), FocusPanel::Results);
1748    }
1749
1750    #[test]
1751    fn test_focus_panel_input_always_reachable() {
1752        // Input is always reachable regardless of visibility
1753        let visibility = PanelVisibility {
1754            tables: false,
1755            results: false,
1756            history: false,
1757        };
1758
1759        // Start from any panel, should eventually reach Input
1760        let mut current = FocusPanel::Tables;
1761        for _ in 0..5 {
1762            current = current.next(&visibility);
1763        }
1764        assert_eq!(current, FocusPanel::Input);
1765    }
1766
1767    // -------------------------------------------------------------------------
1768    // TablesBrowserState Filter Tests
1769    // -------------------------------------------------------------------------
1770
1771    #[test]
1772    fn test_apply_filter_empty_shows_all() {
1773        let mut state = TablesBrowserState::default();
1774        state.entries = vec![
1775            TableEntry {
1776                keyspace: "ks1".to_string(),
1777                name: "table1".to_string(),
1778                qualified_name: "ks1.table1".to_string(),
1779            },
1780            TableEntry {
1781                keyspace: "ks2".to_string(),
1782                name: "table2".to_string(),
1783                qualified_name: "ks2.table2".to_string(),
1784            },
1785            TableEntry {
1786                keyspace: "ks3".to_string(),
1787                name: "users".to_string(),
1788                qualified_name: "ks3.users".to_string(),
1789            },
1790        ];
1791
1792        state.filter_text = String::new();
1793        state.apply_filter();
1794
1795        // All entries should be visible
1796        assert_eq!(state.filtered_indices, vec![0, 1, 2]);
1797    }
1798
1799    #[test]
1800    fn test_apply_filter_matches_some() {
1801        let mut state = TablesBrowserState::default();
1802        state.entries = vec![
1803            TableEntry {
1804                keyspace: "ks1".to_string(),
1805                name: "table1".to_string(),
1806                qualified_name: "ks1.table1".to_string(),
1807            },
1808            TableEntry {
1809                keyspace: "ks2".to_string(),
1810                name: "table2".to_string(),
1811                qualified_name: "ks2.table2".to_string(),
1812            },
1813            TableEntry {
1814                keyspace: "ks3".to_string(),
1815                name: "users".to_string(),
1816                qualified_name: "ks3.users".to_string(),
1817            },
1818        ];
1819
1820        state.filter_text = "table".to_string();
1821        state.apply_filter();
1822
1823        // Only entries containing "table" should be visible
1824        assert_eq!(state.filtered_indices, vec![0, 1]);
1825    }
1826
1827    #[test]
1828    fn test_apply_filter_matches_none() {
1829        let mut state = TablesBrowserState::default();
1830        state.entries = vec![
1831            TableEntry {
1832                keyspace: "ks1".to_string(),
1833                name: "table1".to_string(),
1834                qualified_name: "ks1.table1".to_string(),
1835            },
1836            TableEntry {
1837                keyspace: "ks2".to_string(),
1838                name: "table2".to_string(),
1839                qualified_name: "ks2.table2".to_string(),
1840            },
1841        ];
1842
1843        state.filter_text = "nonexistent".to_string();
1844        state.apply_filter();
1845
1846        // No entries should match
1847        assert_eq!(state.filtered_indices, Vec::<usize>::new());
1848    }
1849
1850    #[test]
1851    fn test_apply_filter_case_insensitive() {
1852        let mut state = TablesBrowserState::default();
1853        state.entries = vec![
1854            TableEntry {
1855                keyspace: "TestKS".to_string(),
1856                name: "Users".to_string(),
1857                qualified_name: "TestKS.Users".to_string(),
1858            },
1859            TableEntry {
1860                keyspace: "prodks".to_string(),
1861                name: "products".to_string(),
1862                qualified_name: "prodks.products".to_string(),
1863            },
1864        ];
1865
1866        state.filter_text = "USERS".to_string();
1867        state.apply_filter();
1868
1869        // Case-insensitive match
1870        assert_eq!(state.filtered_indices, vec![0]);
1871    }
1872
1873    #[test]
1874    fn test_apply_filter_resets_selection_when_out_of_bounds() {
1875        let mut state = TablesBrowserState::default();
1876        state.entries = vec![
1877            TableEntry {
1878                keyspace: "ks1".to_string(),
1879                name: "table1".to_string(),
1880                qualified_name: "ks1.table1".to_string(),
1881            },
1882            TableEntry {
1883                keyspace: "ks2".to_string(),
1884                name: "table2".to_string(),
1885                qualified_name: "ks2.table2".to_string(),
1886            },
1887            TableEntry {
1888                keyspace: "ks3".to_string(),
1889                name: "users".to_string(),
1890                qualified_name: "ks3.users".to_string(),
1891            },
1892        ];
1893
1894        // Select the last item
1895        state.filtered_indices = vec![0, 1, 2];
1896        state.list_state.select(Some(2));
1897
1898        // Apply filter that removes selected item
1899        state.filter_text = "table".to_string();
1900        state.apply_filter();
1901
1902        // Selection should be reset to first item
1903        assert_eq!(state.list_state.selected(), Some(0));
1904        assert_eq!(state.filtered_indices, vec![0, 1]);
1905    }
1906
1907    #[test]
1908    fn test_apply_filter_resets_selection_when_empty() {
1909        let mut state = TablesBrowserState::default();
1910        state.entries = vec![TableEntry {
1911            keyspace: "ks1".to_string(),
1912            name: "table1".to_string(),
1913            qualified_name: "ks1.table1".to_string(),
1914        }];
1915
1916        state.filtered_indices = vec![0];
1917        state.list_state.select(Some(0));
1918
1919        // Apply filter that matches nothing
1920        state.filter_text = "nonexistent".to_string();
1921        state.apply_filter();
1922
1923        // Selection should be cleared
1924        assert_eq!(state.list_state.selected(), None);
1925        assert_eq!(state.filtered_indices, Vec::<usize>::new());
1926    }
1927
1928    // -------------------------------------------------------------------------
1929    // ResultsTableState Tests
1930    // -------------------------------------------------------------------------
1931
1932    #[test]
1933    fn test_calculate_widths_empty_columns() {
1934        let mut state = ResultsTableState::default();
1935        state.calculate_widths();
1936
1937        assert_eq!(state.column_widths, Vec::<u16>::new());
1938    }
1939
1940    #[test]
1941    fn test_calculate_widths_headers_only() {
1942        let mut state = ResultsTableState::default();
1943        state.columns = vec!["id".to_string(), "name".to_string(), "email".to_string()];
1944        state.calculate_widths();
1945
1946        // Widths should be header length + 2 (padding)
1947        // "id" (2) + 2 = 4, "name" (4) + 2 = 6, "email" (5) + 2 = 7
1948        assert_eq!(state.column_widths, vec![4, 6, 7]);
1949    }
1950
1951    #[test]
1952    fn test_calculate_widths_with_data() {
1953        let mut state = ResultsTableState::default();
1954        state.columns = vec!["id".to_string(), "name".to_string()];
1955        state.rows = vec![
1956            vec!["1".to_string(), "Alice".to_string()],
1957            vec!["2".to_string(), "BobTheBuilder".to_string()],
1958        ];
1959        state.calculate_widths();
1960
1961        // id column: max(2, 1) + 2 = 4
1962        // name column: max(4, 13) + 2 = 15
1963        assert_eq!(state.column_widths, vec![4, 15]);
1964    }
1965
1966    #[test]
1967    fn test_calculate_widths_caps_at_40() {
1968        let mut state = ResultsTableState::default();
1969        state.columns = vec!["long_column".to_string()];
1970        state.rows = vec![vec!["a".repeat(100)]];
1971        state.calculate_widths();
1972
1973        // Should cap at 40
1974        assert_eq!(state.column_widths, vec![40]);
1975    }
1976
1977    #[test]
1978    fn test_calculate_widths_samples_first_100_rows() {
1979        let mut state = ResultsTableState::default();
1980        state.columns = vec!["data".to_string()];
1981
1982        // Create 150 rows, with row 101 having the longest content
1983        let mut rows = Vec::new();
1984        for i in 0..150 {
1985            if i == 101 {
1986                rows.push(vec!["very_long_content_here".to_string()]);
1987            } else {
1988                rows.push(vec!["x".to_string()]);
1989            }
1990        }
1991        state.rows = rows;
1992        state.calculate_widths();
1993
1994        // Should only sample first 100, so won't see row 101's long content
1995        // Width should be max(4, 1) + 2 = 6
1996        assert_eq!(state.column_widths, vec![6]);
1997    }
1998
1999    #[test]
2000    fn test_visible_columns_empty() {
2001        let state = ResultsTableState::default();
2002        let visible = state.visible_columns(100);
2003
2004        assert_eq!(visible, 0..0);
2005    }
2006
2007    #[test]
2008    fn test_visible_columns_all_fit() {
2009        let mut state = ResultsTableState::default();
2010        state.columns = vec!["a".to_string(), "b".to_string(), "c".to_string()];
2011        state.column_widths = vec![10, 10, 10];
2012        state.col_offset = 0;
2013
2014        let visible = state.visible_columns(50);
2015
2016        // All columns fit
2017        assert_eq!(visible, 0..3);
2018    }
2019
2020    #[test]
2021    fn test_visible_columns_with_offset() {
2022        let mut state = ResultsTableState::default();
2023        state.columns = vec!["a".to_string(), "b".to_string(), "c".to_string()];
2024        state.column_widths = vec![10, 10, 10];
2025        state.col_offset = 1;
2026
2027        let visible = state.visible_columns(50);
2028
2029        // Should start from offset 1
2030        assert_eq!(visible, 1..3);
2031    }
2032
2033    #[test]
2034    fn test_visible_columns_partial_fit() {
2035        let mut state = ResultsTableState::default();
2036        state.columns = vec!["a".to_string(), "b".to_string(), "c".to_string()];
2037        state.column_widths = vec![15, 15, 15];
2038        state.col_offset = 0;
2039
2040        let visible = state.visible_columns(25);
2041
2042        // Only first 2 columns fit (15 + 15 = 30 > 25, but 15 < 25)
2043        assert_eq!(visible, 0..1);
2044    }
2045
2046    #[test]
2047    fn test_visible_columns_clamps_offset() {
2048        let mut state = ResultsTableState::default();
2049        state.columns = vec!["a".to_string(), "b".to_string()];
2050        state.column_widths = vec![10, 10];
2051        state.col_offset = 100; // Beyond bounds
2052
2053        let visible = state.visible_columns(50);
2054
2055        // Should clamp to last valid column
2056        assert_eq!(visible, 1..2);
2057    }
2058
2059    #[test]
2060    fn test_has_scroll_left() {
2061        let mut state = ResultsTableState::default();
2062
2063        state.col_offset = 0;
2064        assert!(!state.has_scroll_left());
2065
2066        state.col_offset = 1;
2067        assert!(state.has_scroll_left());
2068
2069        state.col_offset = 5;
2070        assert!(state.has_scroll_left());
2071    }
2072
2073    #[test]
2074    fn test_has_scroll_right() {
2075        let mut state = ResultsTableState::default();
2076        state.columns = vec!["a".to_string(), "b".to_string(), "c".to_string()];
2077        state.column_widths = vec![20, 20, 20];
2078        state.col_offset = 0;
2079
2080        // With 30 available width, only first 2 columns visible
2081        assert!(state.has_scroll_right(30));
2082
2083        // With 100 available width, all columns visible
2084        assert!(!state.has_scroll_right(100));
2085    }
2086
2087    #[test]
2088    fn test_has_scroll_right_at_end() {
2089        let mut state = ResultsTableState::default();
2090        state.columns = vec!["a".to_string(), "b".to_string()];
2091        state.column_widths = vec![10, 10];
2092        state.col_offset = 1; // At last column
2093
2094        // At last column, no scroll right
2095        assert!(!state.has_scroll_right(100));
2096    }
2097
2098    #[test]
2099    fn test_clear() {
2100        let mut state = ResultsTableState::default();
2101        state.columns = vec!["a".to_string(), "b".to_string()];
2102        state.rows = vec![vec!["1".to_string(), "2".to_string()]];
2103        state.row_offset = 5;
2104        state.col_offset = 2;
2105        state.selected_row = Some(3);
2106        state.column_widths = vec![10, 20];
2107
2108        state.clear();
2109
2110        assert!(state.columns.is_empty());
2111        assert!(state.rows.is_empty());
2112        assert_eq!(state.row_offset, 0);
2113        assert_eq!(state.col_offset, 0);
2114        assert_eq!(state.selected_row, None);
2115        assert!(state.column_widths.is_empty());
2116    }
2117
2118    // -------------------------------------------------------------------------
2119    // PanelVisibility Tests
2120    // -------------------------------------------------------------------------
2121
2122    #[test]
2123    fn test_panel_visibility_default() {
2124        let visibility = PanelVisibility::default();
2125
2126        assert!(visibility.tables);
2127        assert!(visibility.results);
2128        assert!(visibility.history);
2129    }
2130
2131    #[test]
2132    fn test_panel_visibility_reset() {
2133        let mut visibility = PanelVisibility {
2134            tables: false,
2135            results: false,
2136            history: false,
2137        };
2138
2139        visibility.reset();
2140
2141        assert!(visibility.tables);
2142        assert!(visibility.results);
2143        assert!(visibility.history);
2144    }
2145
2146    #[test]
2147    fn test_panel_visibility_reset_restores_default() {
2148        let mut visibility = PanelVisibility {
2149            tables: true,
2150            results: false,
2151            history: true,
2152        };
2153
2154        visibility.reset();
2155
2156        let default = PanelVisibility::default();
2157        assert_eq!(visibility.tables, default.tables);
2158        assert_eq!(visibility.results, default.results);
2159        assert_eq!(visibility.history, default.history);
2160    }
2161
2162    // -------------------------------------------------------------------------
2163    // format_duration Tests (Microsecond Display Support)
2164    // -------------------------------------------------------------------------
2165
2166    #[test]
2167    fn test_format_duration_microseconds() {
2168        // Sub-millisecond times should display in microseconds
2169        assert_eq!(format_duration(Duration::from_micros(0)), "0μs");
2170        assert_eq!(format_duration(Duration::from_micros(1)), "1μs");
2171        assert_eq!(format_duration(Duration::from_micros(450)), "450μs");
2172        assert_eq!(format_duration(Duration::from_micros(999)), "999μs");
2173    }
2174
2175    #[test]
2176    fn test_format_duration_milliseconds() {
2177        // 1-999ms should display with 1 decimal place
2178        assert_eq!(format_duration(Duration::from_micros(1_000)), "1.0ms");
2179        assert_eq!(format_duration(Duration::from_micros(1_200)), "1.2ms");
2180        assert_eq!(format_duration(Duration::from_micros(7_000)), "7.0ms");
2181        assert_eq!(format_duration(Duration::from_micros(74_000)), "74.0ms");
2182        assert_eq!(format_duration(Duration::from_micros(123_456)), "123.5ms");
2183        assert_eq!(format_duration(Duration::from_micros(999_999)), "1000.0ms");
2184    }
2185
2186    #[test]
2187    fn test_format_duration_seconds() {
2188        // >= 1000ms should display as seconds with 1 decimal place
2189        assert_eq!(format_duration(Duration::from_micros(1_000_000)), "1.0s");
2190        assert_eq!(format_duration(Duration::from_micros(1_500_000)), "1.5s");
2191        assert_eq!(format_duration(Duration::from_micros(2_750_000)), "2.8s");
2192        assert_eq!(format_duration(Duration::from_micros(10_000_000)), "10.0s");
2193        assert_eq!(
2194            format_duration(Duration::from_micros(123_456_789)),
2195            "123.5s"
2196        );
2197    }
2198
2199    #[test]
2200    fn test_format_duration_boundary_cases() {
2201        // Test exact boundaries between units
2202        assert_eq!(format_duration(Duration::from_nanos(999_999)), "999μs");
2203        assert_eq!(format_duration(Duration::from_nanos(1_000_000)), "1.0ms");
2204        assert_eq!(
2205            format_duration(Duration::from_nanos(999_999_999)),
2206            "1000.0ms"
2207        );
2208        assert_eq!(format_duration(Duration::from_nanos(1_000_000_000)), "1.0s");
2209    }
2210
2211    #[test]
2212    fn test_format_duration_typical_query_times() {
2213        // Test typical query execution times seen in the wild
2214        assert_eq!(format_duration(Duration::from_micros(500)), "500μs"); // Fast indexed lookup
2215        assert_eq!(format_duration(Duration::from_micros(3_500)), "3.5ms"); // Normal query
2216        assert_eq!(format_duration(Duration::from_micros(25_000)), "25.0ms"); // Slower query
2217        assert_eq!(format_duration(Duration::from_micros(150_000)), "150.0ms"); // Complex query
2218    }
2219
2220    // -------------------------------------------------------------------------
2221    // History Navigation Tests
2222    // -------------------------------------------------------------------------
2223
2224    #[test]
2225    fn test_navigate_history_empty() {
2226        // Create a minimal TuiApp for testing history navigation
2227        let mut app_state = create_test_app_state();
2228
2229        // Navigating empty history should do nothing
2230        app_state.navigate_history(true);
2231        assert_eq!(app_state.input, "");
2232        assert_eq!(app_state.history_index, None);
2233    }
2234
2235    #[test]
2236    fn test_navigate_history_up_from_fresh() {
2237        let mut app_state = create_test_app_state();
2238
2239        // Add some history
2240        app_state.history.push("SELECT * FROM users".to_string());
2241        app_state.history.push("SELECT * FROM orders".to_string());
2242        app_state.history.push("SELECT * FROM products".to_string());
2243
2244        // Press Up once - should show most recent command
2245        app_state.navigate_history(true);
2246        assert_eq!(app_state.input, "SELECT * FROM products");
2247        assert_eq!(app_state.history_index, Some(2));
2248
2249        // Press Up again - should show previous command
2250        app_state.navigate_history(true);
2251        assert_eq!(app_state.input, "SELECT * FROM orders");
2252        assert_eq!(app_state.history_index, Some(1));
2253
2254        // Press Up again - should show oldest command
2255        app_state.navigate_history(true);
2256        assert_eq!(app_state.input, "SELECT * FROM users");
2257        assert_eq!(app_state.history_index, Some(0));
2258
2259        // Press Up again - should stay at oldest
2260        app_state.navigate_history(true);
2261        assert_eq!(app_state.input, "SELECT * FROM users");
2262        assert_eq!(app_state.history_index, Some(0));
2263    }
2264
2265    #[test]
2266    fn test_navigate_history_down() {
2267        let mut app_state = create_test_app_state();
2268
2269        // Add history
2270        app_state.history.push("command1".to_string());
2271        app_state.history.push("command2".to_string());
2272        app_state.history.push("command3".to_string());
2273
2274        // Navigate to middle of history
2275        app_state.navigate_history(true);
2276        app_state.navigate_history(true);
2277        assert_eq!(app_state.history_index, Some(1));
2278        assert_eq!(app_state.input, "command2");
2279
2280        // Press Down - should go forward
2281        app_state.navigate_history(false);
2282        assert_eq!(app_state.input, "command3");
2283        assert_eq!(app_state.history_index, Some(2));
2284
2285        // Press Down again - should clear input and reset
2286        app_state.navigate_history(false);
2287        assert_eq!(app_state.input, "");
2288        assert_eq!(app_state.history_index, None);
2289
2290        // Press Down when already at end - should do nothing
2291        app_state.navigate_history(false);
2292        assert_eq!(app_state.input, "");
2293        assert_eq!(app_state.history_index, None);
2294    }
2295
2296    #[test]
2297    fn test_navigate_history_cycle() {
2298        let mut app_state = create_test_app_state();
2299
2300        // Add history
2301        app_state.history.push("first".to_string());
2302        app_state.history.push("second".to_string());
2303
2304        // Go up to oldest
2305        app_state.navigate_history(true);
2306        app_state.navigate_history(true);
2307        assert_eq!(app_state.input, "first");
2308
2309        // Go down to newest
2310        app_state.navigate_history(false);
2311        assert_eq!(app_state.input, "second");
2312
2313        // Go down past end - should clear
2314        app_state.navigate_history(false);
2315        assert_eq!(app_state.input, "");
2316        assert_eq!(app_state.history_index, None);
2317
2318        // Can navigate up again from fresh state
2319        app_state.navigate_history(true);
2320        assert_eq!(app_state.input, "second");
2321        assert_eq!(app_state.history_index, Some(1));
2322    }
2323
2324    /// Helper struct for testing history navigation without a full TuiApp
2325    /// This allows us to test the navigate_history logic in isolation
2326    struct TestHistoryState {
2327        input: String,
2328        history: Vec<String>,
2329        history_index: Option<usize>,
2330    }
2331
2332    impl TestHistoryState {
2333        fn new() -> Self {
2334            Self {
2335                input: String::new(),
2336                history: Vec::new(),
2337                history_index: None,
2338            }
2339        }
2340
2341        /// Navigate history (same logic as TuiApp::navigate_history)
2342        fn navigate_history(&mut self, up: bool) {
2343            if self.history.is_empty() {
2344                return;
2345            }
2346
2347            if up {
2348                let index = match self.history_index {
2349                    None => self.history.len() - 1,
2350                    Some(i) if i > 0 => i - 1,
2351                    Some(_) => return,
2352                };
2353                self.history_index = Some(index);
2354                self.input = self.history[index].clone();
2355            } else {
2356                match self.history_index {
2357                    None => return,
2358                    Some(i) if i < self.history.len() - 1 => {
2359                        self.history_index = Some(i + 1);
2360                        self.input = self.history[i + 1].clone();
2361                    }
2362                    Some(_) => {
2363                        self.history_index = None;
2364                        self.input.clear();
2365                    }
2366                }
2367            }
2368        }
2369    }
2370
2371    /// Helper function to create a minimal test state for history navigation
2372    fn create_test_app_state() -> TestHistoryState {
2373        TestHistoryState::new()
2374    }
2375
2376    // -------------------------------------------------------------------------
2377    // extract_table_name Tests (Issue #251)
2378    // -------------------------------------------------------------------------
2379
2380    #[test]
2381    fn test_extract_table_name_valid() {
2382        assert_eq!(
2383            extract_table_name("users-3b7a9d8c"),
2384            Some("users".to_string())
2385        );
2386        assert_eq!(
2387            extract_table_name("test_table-abc123"),
2388            Some("test_table".to_string())
2389        );
2390        assert_eq!(
2391            extract_table_name("MyTable123-uuid"),
2392            Some("MyTable123".to_string())
2393        );
2394    }
2395
2396    #[test]
2397    fn test_extract_table_name_invalid() {
2398        // No dash
2399        assert_eq!(extract_table_name("users"), None);
2400        // Empty before dash
2401        assert_eq!(extract_table_name("-uuid"), None);
2402        // Special characters
2403        assert_eq!(extract_table_name("table.name-uuid"), None);
2404        assert_eq!(
2405            extract_table_name("table-name-uuid"),
2406            Some("table".to_string())
2407        ); // Takes first part before first dash
2408    }
2409
2410    #[test]
2411    fn test_extract_table_name_edge_cases() {
2412        // Multiple dashes - should extract first part
2413        assert_eq!(
2414            extract_table_name("my-table-uuid-1234"),
2415            Some("my".to_string())
2416        );
2417        // Just dash
2418        assert_eq!(extract_table_name("-"), None);
2419        // Empty string
2420        assert_eq!(extract_table_name(""), None);
2421    }
2422}