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 log::set_max_level(log::LevelFilter::Off);
29
30 let db = Arc::new(database);
32
33 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 let mut app = TuiApp::new(db_path, config, db).await?;
42 let res = run_tui(&mut terminal, &mut app).await;
43
44 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#[derive(Debug, Clone, Copy)]
66struct PanelVisibility {
67 tables: bool, results: bool, history: bool, }
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 fn reset(&mut self) {
85 *self = Self::default();
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91enum FocusPanel {
92 Tables,
93 Results,
94 History,
95 Input,
96}
97
98impl FocusPanel {
99 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), ];
107
108 let current_idx = order.iter().position(|(p, _)| *p == self).unwrap_or(3);
109
110 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 }
119
120 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#[derive(Debug, Clone)]
143struct TableEntry {
144 #[allow(dead_code)] keyspace: String,
146 #[allow(dead_code)] name: String,
148 qualified_name: String, }
150
151#[derive(Debug)]
153struct TablesBrowserState {
154 entries: Vec<TableEntry>,
155 filtered_indices: Vec<usize>, filter_text: String,
157 filter_active: bool, 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 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 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 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#[derive(Debug)]
211struct ResultsTableState {
212 columns: Vec<String>,
213 rows: Vec<Vec<String>>,
214 row_offset: usize, col_offset: usize, selected_row: Option<usize>,
217 column_widths: Vec<u16>, 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 fn calculate_widths(&mut self) {
238 if self.columns.is_empty() {
239 self.column_widths = vec![];
240 return;
241 }
242
243 let mut widths: Vec<u16> = self.columns.iter().map(|c| c.len() as u16).collect();
245
246 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 for w in &mut widths {
257 *w = (*w + 2).min(40);
258 }
259
260 self.column_widths = widths;
261 }
262
263 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 fn has_scroll_left(&self) -> bool {
289 self.col_offset > 0
290 }
291
292 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 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
310struct LayoutAreas {
312 header: Rect,
313 tables: Option<Rect>,
314 results: Option<Rect>,
315 history: Option<Rect>,
316 input: Rect,
317 status: Rect,
318}
319
320struct TuiApp {
326 db_path: std::path::PathBuf,
327 database: Arc<Database>,
328 input: String,
329 #[allow(dead_code)] input_mode: InputMode,
331 messages: Vec<String>,
332 #[allow(dead_code)] scroll_offset: usize,
334 history: Vec<String>,
335 history_index: Option<usize>,
336 query_results: Vec<QueryDisplayResult>,
337 #[allow(dead_code)] results_scroll: ListState,
339 show_help: bool,
340 status_message: String,
341 #[allow(dead_code)] last_execution_time: Option<Duration>,
343 status_metrics: Option<StatusMetrics>,
345 metrics_last_updated: Option<Instant>,
347
348 panel_visibility: PanelVisibility,
351 focus_panel: FocusPanel,
353 tables_browser: TablesBrowserState,
355 results_table: ResultsTableState,
357 history_scroll: ListState,
359 current_keyspace: Option<String>,
361}
362
363#[derive(Clone, PartialEq)]
364#[allow(dead_code)] enum 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)] rows: usize,
378 execution_time: Option<Duration>,
379 #[allow(dead_code)] error_message: Option<String>,
381}
382
383impl TuiApp {
384 async fn new(db_path: &Path, config: &Config, database: Arc<Database>) -> Result<Self> {
385 let initial_metrics = StatusMetrics::collect(Some(db_path), Some(&database)).await;
387
388 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 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 app.load_tables(config).await;
421
422 Ok(app)
423 }
424
425 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 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 async fn load_tables(&mut self, config: &Config) {
448 let data_dir = match &config.data_directory {
450 Some(dir) if !dir.as_os_str().is_empty() => dir,
451 _ => {
452 return;
454 }
455 };
456
457 match self.scan_tables(data_dir).await {
459 Ok(tables) => {
460 self.tables_browser.entries = tables;
461 self.tables_browser.apply_filter();
462
463 if !self.tables_browser.filtered_indices.is_empty() {
465 self.tables_browser.list_state.select(Some(0));
466 }
467 }
468 Err(e) => {
469 eprintln!("Warning: Failed to load tables: {}", e);
471 }
472 }
473 }
474
475 async fn scan_tables(&self, data_dir: &Path) -> Result<Vec<TableEntry>> {
480 use std::fs;
481
482 let mut entries = Vec::new();
483
484 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 let keyspace_dir = entry.path();
503 let table_read_dir = match fs::read_dir(&keyspace_dir) {
504 Ok(rd) => rd,
505 Err(_) => continue, };
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 entries.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
532
533 Ok(entries)
534 }
535
536 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 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 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 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 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 }
629 }
630
631 if self.query_results.len() > 20 {
633 self.query_results.truncate(20);
634 }
635
636 if self.messages.len() > 100 {
638 self.messages.drain(0..self.messages.len() - 100);
639 }
640
641 self.input.clear();
642 }
643
644 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
674async fn run_tui<B: Backend>(terminal: &mut Terminal<B>, app: &mut TuiApp) -> Result<()> {
676 loop {
677 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 if handle_key_event(app, key).await {
686 return Ok(()); }
688 }
689 }
690 }
691}
692
693async fn handle_key_event(app: &mut TuiApp, key: event::KeyEvent) -> bool {
695 if app.show_help {
697 app.show_help = false;
698 return false;
699 }
700
701 if app.tables_browser.filter_active {
703 return handle_filter_key(app, key);
704 }
705
706 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 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; }
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; }
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 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 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
798fn 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
817async 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 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 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
862fn 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
905fn 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 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
940async 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 app.history_index = None;
950 }
951 KeyCode::Backspace => {
952 app.input.pop();
953 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
966fn build_layout(area: Rect, visibility: &PanelVisibility) -> LayoutAreas {
972 let vertical_chunks = Layout::default()
974 .direction(Direction::Vertical)
975 .margin(0)
976 .constraints([
977 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), Constraint::Length(3), ])
982 .split(area);
983
984 let main_area = vertical_chunks[1];
985
986 let (tables_area, right_area) = if visibility.tables {
988 let h_chunks = Layout::default()
989 .direction(Direction::Horizontal)
990 .constraints([
991 Constraint::Percentage(25), Constraint::Percentage(75), ])
994 .split(main_area);
995 (Some(h_chunks[0]), h_chunks[1])
996 } else {
997 (None, main_area)
998 };
999
1000 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), Constraint::Percentage(35), ])
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
1027fn 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 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 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 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 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
1122fn 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 app.results_table.columns.is_empty() {
1133 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 let inner_width = area.width.saturating_sub(4); let visible_cols = app.results_table.visible_columns(inner_width);
1157
1158 let column_widths: Vec<u16> = app.results_table.column_widths.clone();
1160
1161 let header_cells: Vec<Cell> = app.results_table.columns[visible_cols.clone()]
1163 .iter()
1164 .enumerate()
1165 .map(|(idx, h)| {
1166 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 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 let visible_height = area.height.saturating_sub(4) as usize; 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 let col_width = column_widths.get(i).copied().unwrap_or(10) as usize;
1204 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 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) .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
1266fn 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 let query_text = if result.query.len() > 60 {
1293 format!("{}…", &result.query[..59])
1294 } else {
1295 result.query.clone()
1296 };
1297
1298 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
1331fn 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
1368fn 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 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
1397fn 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 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
1445fn ui(f: &mut Frame, app: &mut TuiApp) {
1447 if app.show_help {
1448 draw_help(f);
1449 return;
1450 }
1451
1452 let layout = build_layout(f.size(), &app.panel_visibility);
1454
1455 render_header(f, layout.header, app);
1457
1458 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 layout.tables.is_none() && layout.results.is_none() && layout.history.is_none() {
1473 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(f, layout.input, app);
1492
1493 render_status(f, layout.status, app);
1495}
1496
1497fn 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
1577fn 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
1598fn format_duration(duration: Duration) -> String {
1610 let micros = duration.as_micros();
1611
1612 if micros < 1_000 {
1613 format!("{}μs", micros)
1615 } else if micros < 1_000_000 {
1616 format!("{:.1}ms", micros as f64 / 1_000.0)
1618 } else {
1619 format!("{:.1}s", micros as f64 / 1_000_000.0)
1621 }
1622}
1623
1624fn 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#[cfg(test)]
1643mod tests {
1644 use super::*;
1645
1646 #[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 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 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 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 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 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 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 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 let visibility = PanelVisibility {
1754 tables: false,
1755 results: false,
1756 history: false,
1757 };
1758
1759 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 #[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 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 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 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 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 state.filtered_indices = vec![0, 1, 2];
1896 state.list_state.select(Some(2));
1897
1898 state.filter_text = "table".to_string();
1900 state.apply_filter();
1901
1902 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 state.filter_text = "nonexistent".to_string();
1921 state.apply_filter();
1922
1923 assert_eq!(state.list_state.selected(), None);
1925 assert_eq!(state.filtered_indices, Vec::<usize>::new());
1926 }
1927
1928 #[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 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 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 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 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 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 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 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 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; let visible = state.visible_columns(50);
2054
2055 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 assert!(state.has_scroll_right(30));
2082
2083 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; 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 #[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 #[test]
2167 fn test_format_duration_microseconds() {
2168 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 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 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 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 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"); }
2219
2220 #[test]
2225 fn test_navigate_history_empty() {
2226 let mut app_state = create_test_app_state();
2228
2229 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 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 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 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 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 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 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 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 app_state.navigate_history(false);
2282 assert_eq!(app_state.input, "command3");
2283 assert_eq!(app_state.history_index, Some(2));
2284
2285 app_state.navigate_history(false);
2287 assert_eq!(app_state.input, "");
2288 assert_eq!(app_state.history_index, None);
2289
2290 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 app_state.history.push("first".to_string());
2302 app_state.history.push("second".to_string());
2303
2304 app_state.navigate_history(true);
2306 app_state.navigate_history(true);
2307 assert_eq!(app_state.input, "first");
2308
2309 app_state.navigate_history(false);
2311 assert_eq!(app_state.input, "second");
2312
2313 app_state.navigate_history(false);
2315 assert_eq!(app_state.input, "");
2316 assert_eq!(app_state.history_index, None);
2317
2318 app_state.navigate_history(true);
2320 assert_eq!(app_state.input, "second");
2321 assert_eq!(app_state.history_index, Some(1));
2322 }
2323
2324 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 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 fn create_test_app_state() -> TestHistoryState {
2373 TestHistoryState::new()
2374 }
2375
2376 #[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 assert_eq!(extract_table_name("users"), None);
2400 assert_eq!(extract_table_name("-uuid"), None);
2402 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 ); }
2409
2410 #[test]
2411 fn test_extract_table_name_edge_cases() {
2412 assert_eq!(
2414 extract_table_name("my-table-uuid-1234"),
2415 Some("my".to_string())
2416 );
2417 assert_eq!(extract_table_name("-"), None);
2419 assert_eq!(extract_table_name(""), None);
2421 }
2422}