diff --git a/crates/mbr-tui/src/app.rs b/crates/mbr-tui/src/app.rs deleted file mode 100644 index cce46f8..0000000 --- a/crates/mbr-tui/src/app.rs +++ /dev/null @@ -1,1165 +0,0 @@ -//! Application state and logic for the TUI. -//! -//! This module contains the core application state and the main run loop. -//! Integrates with mbr-core services for Metabase data access. - -use std::sync::Arc; - -use crossterm::event::{KeyCode, KeyModifiers}; -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, -}; -use tokio::sync::mpsc; - -use crate::action::{AppAction, ContentTarget, DataRequest}; -use crate::components::{ - ActiveTab, Component, ContentPanel, ContentView, HelpOverlay, InputMode, QueryResultData, - RecordDetailOverlay, StatusBar, -}; -use crate::event::{Event, EventHandler}; -use crate::layout::main::{HEADER_HEIGHT, STATUS_BAR_HEIGHT}; -use crate::service::{AppData, ConnectionStatus, LoadState, ServiceClient, init_service}; - -/// The main application state. -pub struct App { - /// Whether the application should quit - pub should_quit: bool, - /// Currently active tab - active_tab: ActiveTab, - /// Content panel (full width) - content: ContentPanel, - /// Status bar (bottom) - status_bar: StatusBar, - /// Service client for API access (Arc-wrapped for async sharing) - service: Option>, - /// Connection status - connection_status: ConnectionStatus, - /// Application data from API - data: AppData, - /// Action sender for async operations - action_tx: mpsc::UnboundedSender, - /// Action receiver for processing - action_rx: mpsc::UnboundedReceiver, - /// Whether to show help overlay - show_help: bool, - /// Current query request ID for race condition prevention - current_request_id: u64, - /// Whether to show record detail overlay - show_record_detail: bool, - /// Record detail overlay state - record_detail: Option, -} - -impl Default for App { - fn default() -> Self { - Self::new() - } -} - -impl App { - /// Check if any modal is active that should block global navigation. - /// - /// When a modal (sort, filter, or search) is active, global shortcuts - /// like tab switching (1/2/3), help (?), and Tab should be blocked to prevent - /// accidental navigation while the user is focused on the modal. - fn is_modal_active(&self) -> bool { - self.content.is_sort_mode_active() - || self.content.is_filter_mode_active() - || self.content.is_result_search_active() - } - - /// Create a new application instance. - pub fn new() -> Self { - let (action_tx, action_rx) = mpsc::unbounded_channel(); - - // Initialize service client - let (service, connection_status) = match init_service() { - Ok(client) => { - let status = if client.is_authenticated() { - ConnectionStatus::Connecting - } else { - ConnectionStatus::Disconnected - }; - (Some(client), status) - } - Err(e) => (None, ConnectionStatus::Error(e)), - }; - - // Set initial view to Questions - let mut content = ContentPanel::new(); - content.set_view(ContentView::Questions); - - Self { - should_quit: false, - active_tab: ActiveTab::Questions, - content, - status_bar: StatusBar::new(), - service, - connection_status, - data: AppData::default(), - action_tx, - action_rx, - show_help: false, - current_request_id: 0, - show_record_detail: false, - record_detail: None, - } - } - - /// Run the main application loop (async version). - pub async fn run_async( - &mut self, - terminal: &mut ratatui::Terminal, - ) -> std::io::Result<()> { - let event_handler = EventHandler::new(250); - - // Validate authentication on startup if we have a service client - if let Some(service) = &self.service { - if service.is_authenticated() { - self.validate_auth_async().await; - } - } - - // Auto-load Questions data on startup (initial view is Questions) - if self.content.current_view() == ContentView::Questions - && matches!(self.data.questions, LoadState::Idle) - { - let _ = self - .action_tx - .send(AppAction::LoadData(DataRequest::Questions)); - } - - while !self.should_quit { - // Process any pending actions - self.process_actions(); - - // Draw the UI - terminal.draw(|frame| self.draw(frame))?; - - // Handle events - match event_handler.next()? { - Event::Key(key) => self.handle_key(key.code, key.modifiers), - Event::Resize(_, _) => {} // Terminal will redraw automatically - Event::Tick => {} // Can be used for animations/updates - } - } - - Ok(()) - } - - /// Process pending actions from the action queue - fn process_actions(&mut self) { - while let Ok(action) = self.action_rx.try_recv() { - self.handle_action(action); - } - } - - /// Handle an application action - fn handle_action(&mut self, action: AppAction) { - match action { - AppAction::Quit => { - self.should_quit = true; - } - AppAction::NextPanel => { - self.switch_to_tab(self.active_tab.next()); - } - AppAction::PreviousPanel => { - self.switch_to_tab(self.active_tab.previous()); - } - AppAction::Navigate(target) => { - let view = match target { - ContentTarget::Welcome => ContentView::Welcome, - ContentTarget::Questions => ContentView::Questions, - ContentTarget::Collections => ContentView::Collections, - ContentTarget::Databases => ContentView::Databases, - }; - self.content.set_view(view); - } - AppAction::LoadData(request) => { - self.handle_data_request(request); - } - AppAction::ShowError(msg) => { - self.data.questions = LoadState::Error(msg.clone()); - self.status_bar.set_message(format!("Error: {}", msg)); - } - AppAction::ClearError => { - // Reset to Idle state when clearing error - if self.data.questions.is_error() { - self.data.questions = LoadState::Idle; - } - } - AppAction::SetStatus(msg) => { - self.status_bar.set_message(msg); - } - AppAction::ClearStatus => { - self.status_bar.set_message(""); - } - // === Completion Notifications === - AppAction::QuestionsLoaded(questions) => { - let count = questions.len(); - self.data.questions = LoadState::Loaded(questions); - // Sync to content panel for display - self.content.update_questions(&self.data.questions); - self.status_bar - .set_message(format!("Loaded {} questions", count)); - } - AppAction::CollectionsLoaded(collections) => { - let count = collections.len(); - self.data.collections = LoadState::Loaded(collections); - // Sync to content panel for display - self.content.update_collections(&self.data.collections); - self.status_bar - .set_message(format!("Loaded {} collections", count)); - } - AppAction::DatabasesLoaded(databases) => { - let count = databases.len(); - self.data.databases = LoadState::Loaded(databases); - // Sync to content panel for display - self.content.update_databases(&self.data.databases); - self.status_bar - .set_message(format!("Loaded {} databases", count)); - } - AppAction::AuthValidated(user) => { - let display_name = user - .common_name - .clone() - .or_else(|| user.first_name.clone()) - .unwrap_or_else(|| user.email.clone()); - self.connection_status = ConnectionStatus::Connected(display_name.clone()); - self.status_bar - .set_message(format!("Connected as {}", display_name)); - self.data.current_user = Some(user); - } - AppAction::LoadFailed(request, error) => { - // Set error state on the appropriate data based on request type - match request { - DataRequest::Questions - | DataRequest::Refresh - | DataRequest::SearchQuestions(_) - | DataRequest::FilterQuestionsByCollection(_) => { - self.data.questions = LoadState::Error(error.clone()); - self.content.update_questions(&self.data.questions); - } - DataRequest::Collections => { - self.data.collections = LoadState::Error(error.clone()); - self.content.update_collections(&self.data.collections); - } - DataRequest::Databases => { - self.data.databases = LoadState::Error(error.clone()); - self.content.update_databases(&self.data.databases); - } - _ => {} // QuestionDetails and Execute don't use LoadState - } - self.status_bar.set_message(format!("Error: {}", error)); - } - // === Query Execution (Phase 6) === - AppAction::ExecuteQuestion(id) => { - self.execute_question(id); - } - AppAction::QueryResultLoaded(request_id, result_data) => { - // Only process if this is the current request (ignore stale results) - if request_id == self.current_request_id { - let row_count = result_data.rows.len(); - let name = result_data.question_name.clone(); - // Store in centralized App.data - self.data.query_result = Some(result_data.clone()); - // Sync to ContentPanel for display - self.content.set_query_result(result_data); - self.status_bar - .set_message(format!("Query '{}': {} rows", name, row_count)); - } - } - AppAction::QueryFailed(request_id, error) => { - // Only process if this is the current request (ignore stale errors) - if request_id == self.current_request_id { - self.status_bar - .set_message(format!("Query failed: {}", error)); - } - } - AppAction::BackToQuestions => { - // Clear centralized query result data - self.data.query_result = None; - self.content.back_to_questions(); - self.status_bar.set_message("Returned to Questions list"); - } - // === Collection Drill-down (Phase 3) === - AppAction::DrillDownCollection(collection_id, collection_name) => { - // Enter collection questions view - self.content - .enter_collection_questions(collection_id, collection_name.clone()); - self.status_bar - .set_message(format!("Viewing questions in '{}'", collection_name)); - // Trigger data load - let _ = self.action_tx.send(AppAction::LoadData( - DataRequest::FilterQuestionsByCollection(collection_id), - )); - } - AppAction::BackToCollections => { - // Exit collection questions view - self.content.exit_collection_questions(); - self.status_bar.set_message("Returned to Collections list"); - } - - // === Database Drill-down === - AppAction::DrillDownDatabase(database_id, database_name) => { - // Enter database schemas view - self.content - .enter_database_schemas(database_id, database_name.clone()); - self.status_bar - .set_message(format!("Viewing schemas in '{}'", database_name)); - // Trigger data load - let _ = self - .action_tx - .send(AppAction::LoadData(DataRequest::Schemas(database_id))); - } - AppAction::BackToDatabases => { - // Exit database schemas view - self.content.exit_database_schemas(); - self.status_bar.set_message("Returned to Databases list"); - } - AppAction::DrillDownSchema(schema_name) => { - // Get database_id from context - if let Some(database_id) = self.content.get_database_context().map(|(id, _)| id) { - // Enter schema tables view - self.content - .enter_schema_tables(database_id, schema_name.clone()); - self.status_bar - .set_message(format!("Viewing tables in '{}'", schema_name)); - // Trigger data load - let _ = self.action_tx.send(AppAction::LoadData(DataRequest::Tables( - database_id, - schema_name, - ))); - } - } - AppAction::BackToSchemas => { - // Exit schema tables view - self.content.exit_schema_tables(); - self.status_bar.set_message("Returned to Schemas list"); - } - AppAction::DrillDownTable(table_id, table_name) => { - // Get database_id from context - if let Some(database_id) = self.content.get_schema_context().map(|(id, _)| id) { - // Enter table preview view - self.content - .enter_table_preview(database_id, table_id, table_name.clone()); - self.status_bar - .set_message(format!("Loading preview for '{}'...", table_name)); - // Trigger data load - let _ = self - .action_tx - .send(AppAction::LoadData(DataRequest::TablePreview( - database_id, - table_id, - ))); - } - } - AppAction::BackToTables => { - // Exit table preview view - self.content.exit_table_preview(); - self.status_bar.set_message("Returned to Tables list"); - } - AppAction::SchemasLoaded(schemas) => { - // Store schemas in data - self.data.schemas = LoadState::Loaded(schemas.clone()); - // Sync to content panel - self.content.update_schemas(&self.data.schemas); - // Update status - self.status_bar - .set_message(format!("Loaded {} schemas", schemas.len())); - } - AppAction::TablesLoaded(tables) => { - // Store tables in data - self.data.tables = LoadState::Loaded(tables.clone()); - // Sync to content panel - self.content.update_tables(&self.data.tables); - // Update status - self.status_bar - .set_message(format!("Loaded {} tables", tables.len())); - } - AppAction::TablePreviewLoaded(data) => { - // Store query result for table preview - self.data.query_result = Some(data.clone()); - // Set the query result in content panel (uses shared rendering) - self.content.set_table_preview_data(data.clone()); - // Update status - self.status_bar - .set_message(format!("Preview: {} rows loaded", data.rows.len())); - } - } - } - - /// Handle data loading request with background task spawning - fn handle_data_request(&mut self, request: DataRequest) { - // Check if we have a service client - let service = match &self.service { - Some(s) => Arc::clone(s), - None => { - self.status_bar - .set_message("Error: Not connected to Metabase"); - return; - } - }; - - let tx = self.action_tx.clone(); - - match request { - DataRequest::Questions | DataRequest::Refresh => { - // Guard: prevent duplicate requests while loading - if matches!(self.data.questions, LoadState::Loading) { - return; - } - - // Set loading state - self.data.questions = LoadState::Loading; - // Sync to content panel for display - self.content.update_questions(&self.data.questions); - self.status_bar.set_message("Loading questions..."); - - // Spawn background task - tokio::spawn(async move { - match service.fetch_questions(None, Some(50)).await { - Ok(questions) => { - let _ = tx.send(AppAction::QuestionsLoaded(questions)); - } - Err(e) => { - let _ = tx.send(AppAction::LoadFailed(DataRequest::Questions, e)); - } - } - }); - } - DataRequest::SearchQuestions(query) => { - // Guard: prevent duplicate requests while loading - if matches!(self.data.questions, LoadState::Loading) { - return; - } - - // Set loading state - self.data.questions = LoadState::Loading; - // Sync to content panel for display - self.content.update_questions(&self.data.questions); - self.status_bar - .set_message(format!("Searching for '{}'...", query)); - - // Spawn background task with search parameter - tokio::spawn(async move { - match service.fetch_questions(Some(&query), Some(50)).await { - Ok(questions) => { - let _ = tx.send(AppAction::QuestionsLoaded(questions)); - } - Err(e) => { - let _ = tx.send(AppAction::LoadFailed(DataRequest::Questions, e)); - } - } - }); - } - DataRequest::FilterQuestionsByCollection(collection_id) => { - // Guard: prevent duplicate requests while loading - if matches!(self.data.questions, LoadState::Loading) { - return; - } - - // Set loading state - self.data.questions = LoadState::Loading; - // Sync to content panel for display - self.content.update_questions(&self.data.questions); - - let collection_str = collection_id.to_string(); - - // Spawn background task with collection filter - tokio::spawn(async move { - match service - .fetch_questions_by_collection(&collection_str, Some(100)) - .await - { - Ok(questions) => { - let _ = tx.send(AppAction::QuestionsLoaded(questions)); - } - Err(e) => { - let _ = tx.send(AppAction::LoadFailed( - DataRequest::FilterQuestionsByCollection(collection_id), - e, - )); - } - } - }); - } - DataRequest::Collections => { - // Guard: prevent duplicate requests while loading - if matches!(self.data.collections, LoadState::Loading) { - return; - } - - // Set loading state - self.data.collections = LoadState::Loading; - // Sync to content panel for display - self.content.update_collections(&self.data.collections); - self.status_bar.set_message("Loading collections..."); - - // Spawn background task - tokio::spawn(async move { - match service.fetch_collections().await { - Ok(collections) => { - let _ = tx.send(AppAction::CollectionsLoaded(collections)); - } - Err(e) => { - let _ = tx.send(AppAction::LoadFailed(DataRequest::Collections, e)); - } - } - }); - } - DataRequest::Databases => { - // Guard: prevent duplicate requests while loading - if matches!(self.data.databases, LoadState::Loading) { - return; - } - - // Set loading state - self.data.databases = LoadState::Loading; - // Sync to content panel for display - self.content.update_databases(&self.data.databases); - self.status_bar.set_message("Loading databases..."); - - // Spawn background task - tokio::spawn(async move { - match service.fetch_databases().await { - Ok(databases) => { - let _ = tx.send(AppAction::DatabasesLoaded(databases)); - } - Err(e) => { - let _ = tx.send(AppAction::LoadFailed(DataRequest::Databases, e)); - } - } - }); - } - DataRequest::QuestionDetails(id) => { - // Question details loading - placeholder for future implementation - self.status_bar - .set_message(format!("Loading question #{}...", id)); - } - DataRequest::Execute(id) => { - // Execute question query - handled by execute_question method - self.status_bar - .set_message(format!("Executing query #{}...", id)); - // Actual execution is handled through ExecuteQuestion action - } - DataRequest::Schemas(database_id) => { - // Guard: prevent duplicate requests while loading - if matches!(self.data.schemas, LoadState::Loading) { - return; - } - - // Set loading state - self.data.schemas = LoadState::Loading; - // Sync to content panel for display - self.content.update_schemas(&self.data.schemas); - self.status_bar.set_message("Loading schemas..."); - - // Spawn background task - tokio::spawn(async move { - match service.fetch_schemas(database_id).await { - Ok(schemas) => { - let _ = tx.send(AppAction::SchemasLoaded(schemas)); - } - Err(e) => { - let _ = tx - .send(AppAction::LoadFailed(DataRequest::Schemas(database_id), e)); - } - } - }); - } - DataRequest::Tables(database_id, ref schema_name) => { - // Guard: prevent duplicate requests while loading - if matches!(self.data.tables, LoadState::Loading) { - return; - } - - // Set loading state - self.data.tables = LoadState::Loading; - // Sync to content panel for display - self.content.update_tables(&self.data.tables); - self.status_bar - .set_message(format!("Loading tables in '{}'...", schema_name)); - - let schema = schema_name.clone(); - - // Spawn background task - tokio::spawn(async move { - match service.fetch_tables(database_id, &schema).await { - Ok(tables) => { - let _ = tx.send(AppAction::TablesLoaded(tables)); - } - Err(e) => { - let _ = tx.send(AppAction::LoadFailed( - DataRequest::Tables(database_id, schema), - e, - )); - } - } - }); - } - DataRequest::TablePreview(database_id, table_id) => { - // Set status message - self.status_bar.set_message("Loading table preview..."); - - // Spawn background task - tokio::spawn(async move { - match service.preview_table(database_id, table_id, 100).await { - Ok(result) => { - // Convert QueryResult to QueryResultData - let columns: Vec = result - .data - .cols - .iter() - .map(|c| c.display_name.clone()) - .collect(); - - let rows: Vec> = result - .data - .rows - .iter() - .map(|row| { - row.iter() - .map(|v| match v { - serde_json::Value::Null => "—".to_string(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::String(s) => s.clone(), - _ => v.to_string(), - }) - .collect() - }) - .collect(); - - let result_data = QueryResultData { - question_id: table_id, - question_name: format!("Table #{}", table_id), - columns, - rows, - }; - - let _ = tx.send(AppAction::TablePreviewLoaded(result_data)); - } - Err(e) => { - let _ = tx.send(AppAction::LoadFailed( - DataRequest::TablePreview(database_id, table_id), - e, - )); - } - } - }); - } - } - } - - /// Validate authentication asynchronously - async fn validate_auth_async(&mut self) { - if let Some(service) = &self.service { - match service.validate_auth().await { - Ok(user) => { - let display_name = user - .common_name - .clone() - .or_else(|| user.first_name.clone()) - .unwrap_or_else(|| user.email.clone()); - self.connection_status = ConnectionStatus::Connected(display_name.clone()); - self.status_bar - .set_message(format!("Connected as {}", display_name)); - self.data.current_user = Some(user); - } - Err(e) => { - self.connection_status = ConnectionStatus::Error(e.clone()); - self.status_bar.set_message(format!("Auth failed: {}", e)); - } - } - } - } - - /// Execute a question query - fn execute_question(&mut self, id: u32) { - // Check if we have a service client - let service = match &self.service { - Some(s) => Arc::clone(s), - None => { - self.status_bar - .set_message("Error: Not connected to Metabase"); - return; - } - }; - - // Increment request ID to invalidate any in-flight requests - self.current_request_id = self.current_request_id.wrapping_add(1); - let request_id = self.current_request_id; - - // Get question name from loaded questions - let question_name = self - .data - .questions - .data() - .and_then(|qs| qs.iter().find(|q| q.id == id)) - .map(|q| q.name.clone()) - .unwrap_or_else(|| format!("Question #{}", id)); - - self.status_bar - .set_message(format!("Executing '{}'...", question_name)); - - let tx = self.action_tx.clone(); - - tokio::spawn(async move { - match service.execute_question(id).await { - Ok(result) => { - // Convert QueryResult to QueryResultData (TUI-friendly format) - let columns: Vec = result - .data - .cols - .iter() - .map(|c| c.display_name.clone()) - .collect(); - - let rows: Vec> = result - .data - .rows - .iter() - .map(|row| { - row.iter() - .map(|v| match v { - serde_json::Value::Null => "—".to_string(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::String(s) => s.clone(), - _ => v.to_string(), - }) - .collect() - }) - .collect(); - - let result_data = QueryResultData { - question_id: id, - question_name, - columns, - rows, - }; - - let _ = tx.send(AppAction::QueryResultLoaded(request_id, result_data)); - } - Err(e) => { - let _ = tx.send(AppAction::QueryFailed(request_id, e)); - } - } - }); - } - - // ========================================================================= - // Input Handling Helpers - // ========================================================================= - - /// Handle keyboard input when overlay is active (RecordDetail, Help). - /// Returns true if the key was handled. - fn handle_overlay_keys(&mut self, code: KeyCode) -> bool { - // Record detail overlay takes priority when shown - if self.show_record_detail { - match code { - KeyCode::Esc | KeyCode::Enter => { - self.show_record_detail = false; - self.record_detail = None; - } - KeyCode::Up | KeyCode::Char('k') => { - if let Some(ref mut detail) = self.record_detail { - detail.scroll_up(); - } - } - KeyCode::Down | KeyCode::Char('j') => { - if let Some(ref mut detail) = self.record_detail { - detail.scroll_down(); - } - } - _ => {} // Ignore other keys when detail is shown - } - return true; - } - - // Help overlay takes priority when shown - if self.show_help { - match code { - KeyCode::Char('?') | KeyCode::Esc => { - self.show_help = false; - } - _ => {} // Ignore other keys when help is shown - } - return true; - } - - false - } - - /// Handle keyboard input in search mode. - /// Returns true if the key was handled. - fn handle_search_mode_keys(&mut self, code: KeyCode, modifiers: KeyModifiers) -> bool { - if self.content.input_mode() != InputMode::Search { - return false; - } - - match code { - KeyCode::Enter => { - // Execute search - if let Some(query) = self.content.execute_search() { - let _ = self - .action_tx - .send(AppAction::LoadData(DataRequest::SearchQuestions(query))); - } else { - // Empty query: reload all questions - let _ = self - .action_tx - .send(AppAction::LoadData(DataRequest::Questions)); - } - } - KeyCode::Esc => { - // Cancel search mode - self.content.exit_search_mode(); - } - _ => { - // Delegate to content panel for character input - self.content - .handle_key(crossterm::event::KeyEvent::new(code, modifiers)); - } - } - true - } - - /// Handle global keybindings (quit, help, tab switch, refresh). - /// Returns true if the key was handled. - fn handle_global_keys(&mut self, code: KeyCode, modifiers: KeyModifiers) -> bool { - match code { - KeyCode::Char('q') | KeyCode::Char('Q') => { - self.should_quit = true; - true - } - KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { - self.should_quit = true; - true - } - KeyCode::Esc => self.handle_escape_key(), - KeyCode::Char('?') if !self.is_modal_active() => { - self.show_help = true; - true - } - // Tab switching with number keys 1/2/3 - KeyCode::Char('1') if !self.is_modal_active() => { - self.switch_to_tab(ActiveTab::Questions); - true - } - KeyCode::Char('2') if !self.is_modal_active() => { - self.switch_to_tab(ActiveTab::Collections); - true - } - KeyCode::Char('3') if !self.is_modal_active() => { - self.switch_to_tab(ActiveTab::Databases); - true - } - // Tab cycling with Tab/Shift+Tab - KeyCode::Tab if !self.is_modal_active() => { - let new_tab = if modifiers.contains(KeyModifiers::SHIFT) { - self.active_tab.previous() - } else { - self.active_tab.next() - }; - self.switch_to_tab(new_tab); - true - } - KeyCode::BackTab if !self.is_modal_active() => { - self.switch_to_tab(self.active_tab.previous()); - true - } - // Refresh data with 'r' - KeyCode::Char('r') if !self.is_modal_active() => { - self.handle_refresh(); - true - } - _ => false, - } - } - - /// Handle Escape key for navigation back or quit. - /// Returns true if handled. - fn handle_escape_key(&mut self) -> bool { - // Skip if any modal is active (let ContentPanel handle Esc) - if self.is_modal_active() { - return false; - } - - // Navigate back based on current view - if self.content.current_view() == ContentView::QueryResult { - let _ = self.action_tx.send(AppAction::BackToQuestions); - } else if self.content.is_collection_questions_view() { - let _ = self.action_tx.send(AppAction::BackToCollections); - } else if self.content.is_database_schemas_view() { - let _ = self.action_tx.send(AppAction::BackToDatabases); - } else if self.content.is_schema_tables_view() { - let _ = self.action_tx.send(AppAction::BackToSchemas); - } else if self.content.is_table_preview_view() { - let _ = self.action_tx.send(AppAction::BackToTables); - } else if self.content.get_active_search().is_some() { - // Clear active search and reload all questions - self.content.clear_search(); - let _ = self - .action_tx - .send(AppAction::LoadData(DataRequest::Questions)); - } else { - self.should_quit = true; - } - true - } - - /// Handle refresh action for current view. - fn handle_refresh(&mut self) { - let request = match self.content.current_view() { - ContentView::Questions => DataRequest::Questions, - ContentView::Collections => DataRequest::Collections, - ContentView::Databases => DataRequest::Databases, - _ => DataRequest::Refresh, - }; - // Force reload by resetting state to Idle first - match self.content.current_view() { - ContentView::Questions => self.data.questions = LoadState::Idle, - ContentView::Collections => self.data.collections = LoadState::Idle, - ContentView::Databases => self.data.databases = LoadState::Idle, - _ => {} - } - let _ = self.action_tx.send(AppAction::LoadData(request)); - } - - /// Handle Enter key for content-specific actions (execute, drill-down, detail). - /// Returns true if the key was handled. - fn handle_enter_key(&mut self) -> bool { - let view = self.content.current_view(); - let modal_active = - self.content.is_sort_mode_active() || self.content.is_filter_mode_active(); - - match view { - ContentView::Questions => { - if let Some(question_id) = self.content.get_selected_question_id() { - let _ = self.action_tx.send(AppAction::ExecuteQuestion(question_id)); - return true; - } - } - ContentView::Collections => { - if let Some((id, name)) = self.content.get_selected_collection_info() { - let _ = self - .action_tx - .send(AppAction::DrillDownCollection(id, name)); - return true; - } - } - ContentView::Databases => { - if let Some((id, name)) = self.content.get_selected_database_info() { - let _ = self.action_tx.send(AppAction::DrillDownDatabase(id, name)); - return true; - } - } - ContentView::QueryResult if !modal_active => { - if let Some((columns, values)) = self.content.get_selected_record() { - self.record_detail = Some(RecordDetailOverlay::new(columns, values)); - self.show_record_detail = true; - return true; - } - } - _ => {} - } - - // Handle drill-down views - if self.content.is_collection_questions_view() { - if let Some(question_id) = self.content.get_selected_question_id() { - let _ = self.action_tx.send(AppAction::ExecuteQuestion(question_id)); - return true; - } - } - if self.content.is_database_schemas_view() { - if let Some(schema_name) = self.content.get_selected_schema() { - let _ = self.action_tx.send(AppAction::DrillDownSchema(schema_name)); - return true; - } - } - if self.content.is_schema_tables_view() { - if let Some((table_id, table_name)) = self.content.get_selected_table_info() { - let _ = self - .action_tx - .send(AppAction::DrillDownTable(table_id, table_name)); - return true; - } - } - if self.content.is_table_preview_view() && !modal_active { - if let Some((columns, values)) = self.content.get_selected_record() { - self.record_detail = Some(RecordDetailOverlay::new(columns, values)); - self.show_record_detail = true; - return true; - } - } - - false - } - - // ========================================================================= - // Main Input Handler - // ========================================================================= - - /// Handle keyboard input with delegated responsibility. - fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) { - // 1. Overlay handling (highest priority) - if self.handle_overlay_keys(code) { - return; - } - - // 2. Search mode handling - if self.handle_search_mode_keys(code, modifiers) { - return; - } - - // 3. Global keybindings - if self.handle_global_keys(code, modifiers) { - return; - } - - // 4. Enter key for content actions - if code == KeyCode::Enter && self.handle_enter_key() { - return; - } - - // 5. Delegate remaining keys to content panel - self.content - .handle_key(crossterm::event::KeyEvent::new(code, modifiers)); - } - - /// Switch to a specific tab and update content view. - fn switch_to_tab(&mut self, tab: ActiveTab) { - self.active_tab = tab; - let view = match tab { - ActiveTab::Questions => ContentView::Questions, - ActiveTab::Collections => ContentView::Collections, - ActiveTab::Databases => ContentView::Databases, - }; - self.content.set_view(view); - - // Auto-load data when switching to a view with Idle state - match tab { - ActiveTab::Questions => { - if matches!(self.data.questions, LoadState::Idle) { - let _ = self - .action_tx - .send(AppAction::LoadData(DataRequest::Questions)); - } - } - ActiveTab::Collections => { - if matches!(self.data.collections, LoadState::Idle) { - let _ = self - .action_tx - .send(AppAction::LoadData(DataRequest::Collections)); - } - } - ActiveTab::Databases => { - if matches!(self.data.databases, LoadState::Idle) { - let _ = self - .action_tx - .send(AppAction::LoadData(DataRequest::Databases)); - } - } - } - - self.status_bar - .set_message(format!("Viewing: {}", tab.label())); - } - - /// Draw the UI. - fn draw(&mut self, frame: &mut Frame) { - let size = frame.area(); - - // Create main layout: Header with tabs, Content (100% width), Status bar - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(HEADER_HEIGHT), - Constraint::Min(0), // Main content (100% width) - Constraint::Length(STATUS_BAR_HEIGHT), - ]) - .split(size); - - // Draw header with integrated tabs - self.draw_header_with_tabs(frame, main_chunks[0]); - - // Draw content panel (full width, always focused) - self.content.draw(frame, main_chunks[1], true); - - // Draw status bar - self.status_bar.draw(frame, main_chunks[2], false); - - // Draw help overlay if visible - if self.show_help { - HelpOverlay::render(frame, size); - } - - // Draw record detail overlay if visible - if self.show_record_detail { - if let Some(ref mut detail) = self.record_detail { - detail.render(frame, size); - } - } - } - - /// Draw the header with integrated tab bar. - fn draw_header_with_tabs(&self, frame: &mut Frame, area: ratatui::layout::Rect) { - // Build connection indicator - let connection_indicator = match &self.connection_status { - ConnectionStatus::Disconnected => { - Span::styled(" ○ ", Style::default().fg(Color::DarkGray)) - } - ConnectionStatus::Connecting => Span::styled(" ◐ ", Style::default().fg(Color::Yellow)), - ConnectionStatus::Connected(_) => { - Span::styled(" ● ", Style::default().fg(Color::Green)) - } - ConnectionStatus::Error(_) => Span::styled(" ✗ ", Style::default().fg(Color::Red)), - }; - - // Build tab bar - let tabs = [ - ActiveTab::Questions, - ActiveTab::Collections, - ActiveTab::Databases, - ]; - let mut tab_spans: Vec = vec![Span::raw(" ")]; - - for (i, tab) in tabs.iter().enumerate() { - let is_active = *tab == self.active_tab; - let style = if is_active { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD | Modifier::REVERSED) - } else { - Style::default().fg(Color::White) - }; - - // Add tab with number key hint - tab_spans.push(Span::styled(format!(" {} {} ", i + 1, tab.label()), style)); - tab_spans.push(Span::raw(" ")); - } - - // Add connection status at the end - tab_spans.push(Span::styled("│", Style::default().fg(Color::DarkGray))); - tab_spans.push(connection_indicator); - - let header = Paragraph::new(Line::from(tab_spans)).block( - Block::default() - .title(" mbr-tui ") - .title_style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), - ); - frame.render_widget(header, area); - } -} diff --git a/crates/mbr-tui/src/app/action_handler.rs b/crates/mbr-tui/src/app/action_handler.rs new file mode 100644 index 0000000..8e01cae --- /dev/null +++ b/crates/mbr-tui/src/app/action_handler.rs @@ -0,0 +1,273 @@ +//! Application action handling. +//! +//! Processes AppAction events and updates application state accordingly. + +use crate::action::{AppAction, ContentTarget, DataRequest}; +use crate::components::{ContentView, QueryResultData}; +use crate::service::LoadState; + +use super::App; + +impl App { + /// Handle an application action. + pub(super) fn handle_action(&mut self, action: AppAction) { + match action { + AppAction::Quit => { + self.should_quit = true; + } + AppAction::NextPanel => { + self.switch_to_tab(self.active_tab.next()); + } + AppAction::PreviousPanel => { + self.switch_to_tab(self.active_tab.previous()); + } + AppAction::Navigate(target) => { + let view = match target { + ContentTarget::Welcome => ContentView::Welcome, + ContentTarget::Questions => ContentView::Questions, + ContentTarget::Collections => ContentView::Collections, + ContentTarget::Databases => ContentView::Databases, + }; + self.content.set_view(view); + } + AppAction::LoadData(request) => { + self.handle_data_request(request); + } + AppAction::ShowError(msg) => { + self.data.questions = LoadState::Error(msg.clone()); + self.status_bar.set_message(format!("Error: {}", msg)); + } + AppAction::ClearError => { + if self.data.questions.is_error() { + self.data.questions = LoadState::Idle; + } + } + AppAction::SetStatus(msg) => { + self.status_bar.set_message(msg); + } + AppAction::ClearStatus => { + self.status_bar.set_message(""); + } + // === Completion Notifications === + AppAction::QuestionsLoaded(questions) => { + self.handle_questions_loaded(questions); + } + AppAction::CollectionsLoaded(collections) => { + self.handle_collections_loaded(collections); + } + AppAction::DatabasesLoaded(databases) => { + self.handle_databases_loaded(databases); + } + AppAction::AuthValidated(user) => { + self.handle_auth_validated(user); + } + AppAction::LoadFailed(request, error) => { + self.handle_load_failed(request, error); + } + // === Query Execution === + AppAction::ExecuteQuestion(id) => { + self.execute_question(id); + } + AppAction::QueryResultLoaded(request_id, result_data) => { + self.handle_query_result_loaded(request_id, result_data); + } + AppAction::QueryFailed(request_id, error) => { + self.handle_query_failed(request_id, error); + } + AppAction::BackToQuestions => { + self.data.query_result = None; + self.content.back_to_questions(); + self.status_bar.set_message("Returned to Questions list"); + } + // === Collection Drill-down === + AppAction::DrillDownCollection(collection_id, collection_name) => { + self.handle_drill_down_collection(collection_id, collection_name); + } + AppAction::BackToCollections => { + self.content.exit_collection_questions(); + self.status_bar.set_message("Returned to Collections list"); + } + // === Database Drill-down === + AppAction::DrillDownDatabase(database_id, database_name) => { + self.handle_drill_down_database(database_id, database_name); + } + AppAction::BackToDatabases => { + self.content.exit_database_schemas(); + self.status_bar.set_message("Returned to Databases list"); + } + AppAction::DrillDownSchema(schema_name) => { + self.handle_drill_down_schema(schema_name); + } + AppAction::BackToSchemas => { + self.content.exit_schema_tables(); + self.status_bar.set_message("Returned to Schemas list"); + } + AppAction::DrillDownTable(table_id, table_name) => { + self.handle_drill_down_table(table_id, table_name); + } + AppAction::BackToTables => { + self.content.exit_table_preview(); + self.status_bar.set_message("Returned to Tables list"); + } + AppAction::SchemasLoaded(schemas) => { + self.handle_schemas_loaded(schemas); + } + AppAction::TablesLoaded(tables) => { + self.handle_tables_loaded(tables); + } + AppAction::TablePreviewLoaded(data) => { + self.handle_table_preview_loaded(data); + } + } + } + + // === Action Handlers === + + fn handle_questions_loaded(&mut self, questions: Vec) { + let count = questions.len(); + self.data.questions = LoadState::Loaded(questions); + self.content.update_questions(&self.data.questions); + self.status_bar + .set_message(format!("Loaded {} questions", count)); + } + + fn handle_collections_loaded( + &mut self, + collections: Vec, + ) { + let count = collections.len(); + self.data.collections = LoadState::Loaded(collections); + self.content.update_collections(&self.data.collections); + self.status_bar + .set_message(format!("Loaded {} collections", count)); + } + + fn handle_databases_loaded(&mut self, databases: Vec) { + let count = databases.len(); + self.data.databases = LoadState::Loaded(databases); + self.content.update_databases(&self.data.databases); + self.status_bar + .set_message(format!("Loaded {} databases", count)); + } + + fn handle_auth_validated(&mut self, user: mbr_core::api::models::CurrentUser) { + let display_name = user + .common_name + .clone() + .or_else(|| user.first_name.clone()) + .unwrap_or_else(|| user.email.clone()); + self.connection_status = crate::service::ConnectionStatus::Connected(display_name.clone()); + self.status_bar + .set_message(format!("Connected as {}", display_name)); + self.data.current_user = Some(user); + } + + fn handle_load_failed(&mut self, request: DataRequest, error: String) { + match request { + DataRequest::Questions + | DataRequest::Refresh + | DataRequest::SearchQuestions(_) + | DataRequest::FilterQuestionsByCollection(_) => { + self.data.questions = LoadState::Error(error.clone()); + self.content.update_questions(&self.data.questions); + } + DataRequest::Collections => { + self.data.collections = LoadState::Error(error.clone()); + self.content.update_collections(&self.data.collections); + } + DataRequest::Databases => { + self.data.databases = LoadState::Error(error.clone()); + self.content.update_databases(&self.data.databases); + } + _ => {} + } + self.status_bar.set_message(format!("Error: {}", error)); + } + + fn handle_query_result_loaded(&mut self, request_id: u64, result_data: QueryResultData) { + if request_id == self.current_request_id { + let row_count = result_data.rows.len(); + let name = result_data.question_name.clone(); + self.data.query_result = Some(result_data.clone()); + self.content.set_query_result(result_data); + self.status_bar + .set_message(format!("Query '{}': {} rows", name, row_count)); + } + } + + fn handle_query_failed(&mut self, request_id: u64, error: String) { + if request_id == self.current_request_id { + self.status_bar + .set_message(format!("Query failed: {}", error)); + } + } + + fn handle_drill_down_collection(&mut self, collection_id: u32, collection_name: String) { + self.content + .enter_collection_questions(collection_id, collection_name.clone()); + self.status_bar + .set_message(format!("Viewing questions in '{}'", collection_name)); + let _ = self.action_tx.send(AppAction::LoadData( + DataRequest::FilterQuestionsByCollection(collection_id), + )); + } + + fn handle_drill_down_database(&mut self, database_id: u32, database_name: String) { + self.content + .enter_database_schemas(database_id, database_name.clone()); + self.status_bar + .set_message(format!("Viewing schemas in '{}'", database_name)); + let _ = self + .action_tx + .send(AppAction::LoadData(DataRequest::Schemas(database_id))); + } + + fn handle_drill_down_schema(&mut self, schema_name: String) { + if let Some(database_id) = self.content.get_database_context().map(|(id, _)| id) { + self.content + .enter_schema_tables(database_id, schema_name.clone()); + self.status_bar + .set_message(format!("Viewing tables in '{}'", schema_name)); + let _ = self.action_tx.send(AppAction::LoadData(DataRequest::Tables( + database_id, + schema_name, + ))); + } + } + + fn handle_drill_down_table(&mut self, table_id: u32, table_name: String) { + if let Some(database_id) = self.content.get_schema_context().map(|(id, _)| id) { + self.content + .enter_table_preview(database_id, table_id, table_name.clone()); + self.status_bar + .set_message(format!("Loading preview for '{}'...", table_name)); + let _ = self + .action_tx + .send(AppAction::LoadData(DataRequest::TablePreview( + database_id, + table_id, + ))); + } + } + + fn handle_schemas_loaded(&mut self, schemas: Vec) { + self.data.schemas = LoadState::Loaded(schemas.clone()); + self.content.update_schemas(&self.data.schemas); + self.status_bar + .set_message(format!("Loaded {} schemas", schemas.len())); + } + + fn handle_tables_loaded(&mut self, tables: Vec) { + self.data.tables = LoadState::Loaded(tables.clone()); + self.content.update_tables(&self.data.tables); + self.status_bar + .set_message(format!("Loaded {} tables", tables.len())); + } + + fn handle_table_preview_loaded(&mut self, data: QueryResultData) { + self.data.query_result = Some(data.clone()); + self.content.set_table_preview_data(data.clone()); + self.status_bar + .set_message(format!("Preview: {} rows loaded", data.rows.len())); + } +} diff --git a/crates/mbr-tui/src/app/data_handler.rs b/crates/mbr-tui/src/app/data_handler.rs new file mode 100644 index 0000000..d826252 --- /dev/null +++ b/crates/mbr-tui/src/app/data_handler.rs @@ -0,0 +1,381 @@ +//! Data loading request handling. +//! +//! Handles DataRequest events by spawning async tasks to fetch data from the API. + +use std::sync::Arc; + +use crate::action::{AppAction, DataRequest}; +use crate::components::QueryResultData; +use crate::service::LoadState; + +use super::App; + +impl App { + /// Handle data loading request with background task spawning. + pub(super) fn handle_data_request(&mut self, request: DataRequest) { + let service = match &self.service { + Some(s) => Arc::clone(s), + None => { + self.status_bar + .set_message("Error: Not connected to Metabase"); + return; + } + }; + + let tx = self.action_tx.clone(); + + match request { + DataRequest::Questions | DataRequest::Refresh => { + self.load_questions(service, tx); + } + DataRequest::SearchQuestions(query) => { + self.search_questions(service, tx, query); + } + DataRequest::FilterQuestionsByCollection(collection_id) => { + self.load_collection_questions(service, tx, collection_id); + } + DataRequest::Collections => { + self.load_collections(service, tx); + } + DataRequest::Databases => { + self.load_databases(service, tx); + } + DataRequest::QuestionDetails(id) => { + self.status_bar + .set_message(format!("Loading question #{}...", id)); + } + DataRequest::Execute(id) => { + self.status_bar + .set_message(format!("Executing query #{}...", id)); + } + DataRequest::Schemas(database_id) => { + self.load_schemas(service, tx, database_id); + } + DataRequest::Tables(database_id, schema_name) => { + self.load_tables(service, tx, database_id, schema_name); + } + DataRequest::TablePreview(database_id, table_id) => { + self.load_table_preview(service, tx, database_id, table_id); + } + } + } + + fn load_questions( + &mut self, + service: Arc, + tx: tokio::sync::mpsc::UnboundedSender, + ) { + if matches!(self.data.questions, LoadState::Loading) { + return; + } + + self.data.questions = LoadState::Loading; + self.content.update_questions(&self.data.questions); + self.status_bar.set_message("Loading questions..."); + + tokio::spawn(async move { + match service.fetch_questions(None, Some(50)).await { + Ok(questions) => { + let _ = tx.send(AppAction::QuestionsLoaded(questions)); + } + Err(e) => { + let _ = tx.send(AppAction::LoadFailed(DataRequest::Questions, e)); + } + } + }); + } + + fn search_questions( + &mut self, + service: Arc, + tx: tokio::sync::mpsc::UnboundedSender, + query: String, + ) { + if matches!(self.data.questions, LoadState::Loading) { + return; + } + + self.data.questions = LoadState::Loading; + self.content.update_questions(&self.data.questions); + self.status_bar + .set_message(format!("Searching for '{}'...", query)); + + tokio::spawn(async move { + match service.fetch_questions(Some(&query), Some(50)).await { + Ok(questions) => { + let _ = tx.send(AppAction::QuestionsLoaded(questions)); + } + Err(e) => { + let _ = tx.send(AppAction::LoadFailed(DataRequest::Questions, e)); + } + } + }); + } + + fn load_collection_questions( + &mut self, + service: Arc, + tx: tokio::sync::mpsc::UnboundedSender, + collection_id: u32, + ) { + if matches!(self.data.questions, LoadState::Loading) { + return; + } + + self.data.questions = LoadState::Loading; + self.content.update_questions(&self.data.questions); + + let collection_str = collection_id.to_string(); + + tokio::spawn(async move { + match service + .fetch_questions_by_collection(&collection_str, Some(100)) + .await + { + Ok(questions) => { + let _ = tx.send(AppAction::QuestionsLoaded(questions)); + } + Err(e) => { + let _ = tx.send(AppAction::LoadFailed( + DataRequest::FilterQuestionsByCollection(collection_id), + e, + )); + } + } + }); + } + + fn load_collections( + &mut self, + service: Arc, + tx: tokio::sync::mpsc::UnboundedSender, + ) { + if matches!(self.data.collections, LoadState::Loading) { + return; + } + + self.data.collections = LoadState::Loading; + self.content.update_collections(&self.data.collections); + self.status_bar.set_message("Loading collections..."); + + tokio::spawn(async move { + match service.fetch_collections().await { + Ok(collections) => { + let _ = tx.send(AppAction::CollectionsLoaded(collections)); + } + Err(e) => { + let _ = tx.send(AppAction::LoadFailed(DataRequest::Collections, e)); + } + } + }); + } + + fn load_databases( + &mut self, + service: Arc, + tx: tokio::sync::mpsc::UnboundedSender, + ) { + if matches!(self.data.databases, LoadState::Loading) { + return; + } + + self.data.databases = LoadState::Loading; + self.content.update_databases(&self.data.databases); + self.status_bar.set_message("Loading databases..."); + + tokio::spawn(async move { + match service.fetch_databases().await { + Ok(databases) => { + let _ = tx.send(AppAction::DatabasesLoaded(databases)); + } + Err(e) => { + let _ = tx.send(AppAction::LoadFailed(DataRequest::Databases, e)); + } + } + }); + } + + fn load_schemas( + &mut self, + service: Arc, + tx: tokio::sync::mpsc::UnboundedSender, + database_id: u32, + ) { + if matches!(self.data.schemas, LoadState::Loading) { + return; + } + + self.data.schemas = LoadState::Loading; + self.content.update_schemas(&self.data.schemas); + self.status_bar.set_message("Loading schemas..."); + + tokio::spawn(async move { + match service.fetch_schemas(database_id).await { + Ok(schemas) => { + let _ = tx.send(AppAction::SchemasLoaded(schemas)); + } + Err(e) => { + let _ = tx.send(AppAction::LoadFailed(DataRequest::Schemas(database_id), e)); + } + } + }); + } + + fn load_tables( + &mut self, + service: Arc, + tx: tokio::sync::mpsc::UnboundedSender, + database_id: u32, + schema_name: String, + ) { + if matches!(self.data.tables, LoadState::Loading) { + return; + } + + self.data.tables = LoadState::Loading; + self.content.update_tables(&self.data.tables); + self.status_bar + .set_message(format!("Loading tables in '{}'...", schema_name)); + + let schema = schema_name.clone(); + + tokio::spawn(async move { + match service.fetch_tables(database_id, &schema).await { + Ok(tables) => { + let _ = tx.send(AppAction::TablesLoaded(tables)); + } + Err(e) => { + let _ = tx.send(AppAction::LoadFailed( + DataRequest::Tables(database_id, schema), + e, + )); + } + } + }); + } + + fn load_table_preview( + &mut self, + service: Arc, + tx: tokio::sync::mpsc::UnboundedSender, + database_id: u32, + table_id: u32, + ) { + self.status_bar.set_message("Loading table preview..."); + + tokio::spawn(async move { + match service.preview_table(database_id, table_id, 100).await { + Ok(result) => { + let columns: Vec = result + .data + .cols + .iter() + .map(|c| c.display_name.clone()) + .collect(); + + let rows: Vec> = result + .data + .rows + .iter() + .map(|row| { + row.iter() + .map(|v| match v { + serde_json::Value::Null => "—".to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(s) => s.clone(), + _ => v.to_string(), + }) + .collect() + }) + .collect(); + + let result_data = QueryResultData { + question_id: table_id, + question_name: format!("Table #{}", table_id), + columns, + rows, + }; + + let _ = tx.send(AppAction::TablePreviewLoaded(result_data)); + } + Err(e) => { + let _ = tx.send(AppAction::LoadFailed( + DataRequest::TablePreview(database_id, table_id), + e, + )); + } + } + }); + } + + /// Execute a question query. + pub(super) fn execute_question(&mut self, id: u32) { + let service = match &self.service { + Some(s) => Arc::clone(s), + None => { + self.status_bar + .set_message("Error: Not connected to Metabase"); + return; + } + }; + + self.current_request_id = self.current_request_id.wrapping_add(1); + let request_id = self.current_request_id; + + let question_name = self + .data + .questions + .data() + .and_then(|qs| qs.iter().find(|q| q.id == id)) + .map(|q| q.name.clone()) + .unwrap_or_else(|| format!("Question #{}", id)); + + self.status_bar + .set_message(format!("Executing '{}'...", question_name)); + + let tx = self.action_tx.clone(); + + tokio::spawn(async move { + match service.execute_question(id).await { + Ok(result) => { + let columns: Vec = result + .data + .cols + .iter() + .map(|c| c.display_name.clone()) + .collect(); + + let rows: Vec> = result + .data + .rows + .iter() + .map(|row| { + row.iter() + .map(|v| match v { + serde_json::Value::Null => "—".to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(s) => s.clone(), + _ => v.to_string(), + }) + .collect() + }) + .collect(); + + let result_data = QueryResultData { + question_id: id, + question_name, + columns, + rows, + }; + + let _ = tx.send(AppAction::QueryResultLoaded(request_id, result_data)); + } + Err(e) => { + let _ = tx.send(AppAction::QueryFailed(request_id, e)); + } + } + }); + } +} diff --git a/crates/mbr-tui/src/app/input_handler.rs b/crates/mbr-tui/src/app/input_handler.rs new file mode 100644 index 0000000..0b5fb52 --- /dev/null +++ b/crates/mbr-tui/src/app/input_handler.rs @@ -0,0 +1,285 @@ +//! Keyboard input handling for the application. +//! +//! Processes key events and delegates to appropriate handlers based on +//! current application state (overlays, modals, content panels). + +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::action::{AppAction, DataRequest}; +use crate::components::{ContentView, InputMode, RecordDetailOverlay}; +use crate::service::LoadState; + +use super::App; + +impl App { + /// Handle keyboard input with delegated responsibility. + pub(super) fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) { + // 1. Overlay handling (highest priority) + if self.handle_overlay_keys(code) { + return; + } + + // 2. Search mode handling + if self.handle_search_mode_keys(code, modifiers) { + return; + } + + // 3. Global keybindings + if self.handle_global_keys(code, modifiers) { + return; + } + + // 4. Enter key for content actions + if code == KeyCode::Enter && self.handle_enter_key() { + return; + } + + // 5. Delegate remaining keys to content panel + self.content + .handle_key_event(crossterm::event::KeyEvent::new(code, modifiers)); + } + + /// Handle keyboard input when overlay is active (RecordDetail, Help). + /// Returns true if the key was handled. + pub(super) fn handle_overlay_keys(&mut self, code: KeyCode) -> bool { + // Record detail overlay takes priority when shown + if self.show_record_detail { + match code { + KeyCode::Esc | KeyCode::Enter => { + self.show_record_detail = false; + self.record_detail = None; + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(ref mut detail) = self.record_detail { + detail.scroll_up(); + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(ref mut detail) = self.record_detail { + detail.scroll_down(); + } + } + _ => {} + } + return true; + } + + // Help overlay takes priority when shown + if self.show_help { + match code { + KeyCode::Char('?') | KeyCode::Esc => { + self.show_help = false; + } + _ => {} + } + return true; + } + + false + } + + /// Handle keyboard input in search mode. + /// Returns true if the key was handled. + pub(super) fn handle_search_mode_keys( + &mut self, + code: KeyCode, + modifiers: KeyModifiers, + ) -> bool { + if self.content.input_mode() != InputMode::Search { + return false; + } + + match code { + KeyCode::Enter => { + if let Some(query) = self.content.execute_search() { + let _ = self + .action_tx + .send(AppAction::LoadData(DataRequest::SearchQuestions(query))); + } else { + let _ = self + .action_tx + .send(AppAction::LoadData(DataRequest::Questions)); + } + } + KeyCode::Esc => { + self.content.exit_search_mode(); + } + _ => { + self.content + .handle_key_event(crossterm::event::KeyEvent::new(code, modifiers)); + } + } + true + } + + /// Handle global keybindings (quit, help, tab switch, refresh). + /// Returns true if the key was handled. + pub(super) fn handle_global_keys(&mut self, code: KeyCode, modifiers: KeyModifiers) -> bool { + match code { + KeyCode::Char('q') | KeyCode::Char('Q') => { + self.should_quit = true; + true + } + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + true + } + KeyCode::Esc => self.handle_escape_key(), + KeyCode::Char('?') if !self.is_modal_active() => { + self.show_help = true; + true + } + // Tab switching with number keys 1/2/3 + KeyCode::Char('1') if !self.is_modal_active() => { + self.switch_to_tab(crate::components::ActiveTab::Questions); + true + } + KeyCode::Char('2') if !self.is_modal_active() => { + self.switch_to_tab(crate::components::ActiveTab::Collections); + true + } + KeyCode::Char('3') if !self.is_modal_active() => { + self.switch_to_tab(crate::components::ActiveTab::Databases); + true + } + // Tab cycling with Tab/Shift+Tab + KeyCode::Tab if !self.is_modal_active() => { + let new_tab = if modifiers.contains(KeyModifiers::SHIFT) { + self.active_tab.previous() + } else { + self.active_tab.next() + }; + self.switch_to_tab(new_tab); + true + } + KeyCode::BackTab if !self.is_modal_active() => { + self.switch_to_tab(self.active_tab.previous()); + true + } + // Refresh data with 'r' + KeyCode::Char('r') if !self.is_modal_active() => { + self.handle_refresh(); + true + } + _ => false, + } + } + + /// Handle Escape key for navigation back or quit. + /// Returns true if handled. + fn handle_escape_key(&mut self) -> bool { + // Skip if any modal is active (let ContentPanel handle Esc) + if self.is_modal_active() { + return false; + } + + // Navigate back based on current view + if self.content.current_view() == ContentView::QueryResult { + let _ = self.action_tx.send(AppAction::BackToQuestions); + } else if self.content.is_collection_questions_view() { + let _ = self.action_tx.send(AppAction::BackToCollections); + } else if self.content.is_database_schemas_view() { + let _ = self.action_tx.send(AppAction::BackToDatabases); + } else if self.content.is_schema_tables_view() { + let _ = self.action_tx.send(AppAction::BackToSchemas); + } else if self.content.is_table_preview_view() { + let _ = self.action_tx.send(AppAction::BackToTables); + } else if self.content.get_active_search().is_some() { + self.content.clear_search(); + let _ = self + .action_tx + .send(AppAction::LoadData(DataRequest::Questions)); + } else { + self.should_quit = true; + } + true + } + + /// Handle refresh action for current view. + fn handle_refresh(&mut self) { + let request = match self.content.current_view() { + ContentView::Questions => DataRequest::Questions, + ContentView::Collections => DataRequest::Collections, + ContentView::Databases => DataRequest::Databases, + _ => DataRequest::Refresh, + }; + // Force reload by resetting state to Idle first + match self.content.current_view() { + ContentView::Questions => self.data.questions = LoadState::Idle, + ContentView::Collections => self.data.collections = LoadState::Idle, + ContentView::Databases => self.data.databases = LoadState::Idle, + _ => {} + } + let _ = self.action_tx.send(AppAction::LoadData(request)); + } + + /// Handle Enter key for content-specific actions (execute, drill-down, detail). + /// Returns true if the key was handled. + pub(super) fn handle_enter_key(&mut self) -> bool { + let view = self.content.current_view(); + let modal_active = + self.content.is_sort_mode_active() || self.content.is_filter_mode_active(); + + match view { + ContentView::Questions => { + if let Some(question_id) = self.content.get_selected_question_id() { + let _ = self.action_tx.send(AppAction::ExecuteQuestion(question_id)); + return true; + } + } + ContentView::Collections => { + if let Some((id, name)) = self.content.get_selected_collection_info() { + let _ = self + .action_tx + .send(AppAction::DrillDownCollection(id, name)); + return true; + } + } + ContentView::Databases => { + if let Some((id, name)) = self.content.get_selected_database_info() { + let _ = self.action_tx.send(AppAction::DrillDownDatabase(id, name)); + return true; + } + } + ContentView::QueryResult if !modal_active => { + if let Some((columns, values)) = self.content.get_selected_record() { + self.record_detail = Some(RecordDetailOverlay::new(columns, values)); + self.show_record_detail = true; + return true; + } + } + _ => {} + } + + // Handle drill-down views + if self.content.is_collection_questions_view() { + if let Some(question_id) = self.content.get_selected_question_id() { + let _ = self.action_tx.send(AppAction::ExecuteQuestion(question_id)); + return true; + } + } + if self.content.is_database_schemas_view() { + if let Some(schema_name) = self.content.get_selected_schema() { + let _ = self.action_tx.send(AppAction::DrillDownSchema(schema_name)); + return true; + } + } + if self.content.is_schema_tables_view() { + if let Some((table_id, table_name)) = self.content.get_selected_table_info() { + let _ = self + .action_tx + .send(AppAction::DrillDownTable(table_id, table_name)); + return true; + } + } + if self.content.is_table_preview_view() && !modal_active { + if let Some((columns, values)) = self.content.get_selected_record() { + self.record_detail = Some(RecordDetailOverlay::new(columns, values)); + self.show_record_detail = true; + return true; + } + } + + false + } +} diff --git a/crates/mbr-tui/src/app/mod.rs b/crates/mbr-tui/src/app/mod.rs new file mode 100644 index 0000000..940166c --- /dev/null +++ b/crates/mbr-tui/src/app/mod.rs @@ -0,0 +1,325 @@ +//! Application state and logic for the TUI. +//! +//! This module contains the core application state and the main run loop. +//! Integrates with mbr-core services for Metabase data access. +//! +//! ## Module Structure +//! - `mod.rs`: App struct definition, initialization, and rendering +//! - `action_handler.rs`: AppAction event processing +//! - `data_handler.rs`: Async data loading with tokio tasks +//! - `input_handler.rs`: Keyboard event processing + +mod action_handler; +mod data_handler; +mod input_handler; + +use std::sync::Arc; + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, +}; +use tokio::sync::mpsc; + +use crate::action::{AppAction, DataRequest}; +use crate::components::{ + ActiveTab, Component, ContentPanel, ContentView, HelpOverlay, RecordDetailOverlay, StatusBar, +}; +use crate::event::{Event, EventHandler}; +use crate::layout::main::{HEADER_HEIGHT, STATUS_BAR_HEIGHT}; +use crate::service::{AppData, ConnectionStatus, LoadState, ServiceClient, init_service}; + +/// The main application state. +pub struct App { + /// Whether the application should quit + pub should_quit: bool, + /// Currently active tab + pub(crate) active_tab: ActiveTab, + /// Content panel (full width) + pub(crate) content: ContentPanel, + /// Status bar (bottom) + pub(crate) status_bar: StatusBar, + /// Service client for API access (Arc-wrapped for async sharing) + pub(crate) service: Option>, + /// Connection status + pub(crate) connection_status: ConnectionStatus, + /// Application data from API + pub(crate) data: AppData, + /// Action sender for async operations + pub(crate) action_tx: mpsc::UnboundedSender, + /// Action receiver for processing + action_rx: mpsc::UnboundedReceiver, + /// Whether to show help overlay + pub(crate) show_help: bool, + /// Current query request ID for race condition prevention + pub(crate) current_request_id: u64, + /// Whether to show record detail overlay + pub(crate) show_record_detail: bool, + /// Record detail overlay state + pub(crate) record_detail: Option, +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +impl App { + /// Check if any modal is active that should block global navigation. + /// + /// When a modal (sort, filter, or search) is active, global shortcuts + /// like tab switching (1/2/3), help (?), and Tab should be blocked to prevent + /// accidental navigation while the user is focused on the modal. + pub(crate) fn is_modal_active(&self) -> bool { + self.content.is_sort_mode_active() + || self.content.is_filter_mode_active() + || self.content.is_result_search_active() + } + + /// Create a new application instance. + pub fn new() -> Self { + let (action_tx, action_rx) = mpsc::unbounded_channel(); + + // Initialize service client + let (service, connection_status) = match init_service() { + Ok(client) => { + let status = if client.is_authenticated() { + ConnectionStatus::Connecting + } else { + ConnectionStatus::Disconnected + }; + (Some(client), status) + } + Err(e) => (None, ConnectionStatus::Error(e)), + }; + + // Set initial view to Questions + let mut content = ContentPanel::new(); + content.set_view(ContentView::Questions); + + Self { + should_quit: false, + active_tab: ActiveTab::Questions, + content, + status_bar: StatusBar::new(), + service, + connection_status, + data: AppData::default(), + action_tx, + action_rx, + show_help: false, + current_request_id: 0, + show_record_detail: false, + record_detail: None, + } + } + + /// Run the main application loop (async version). + pub async fn run_async( + &mut self, + terminal: &mut ratatui::Terminal, + ) -> std::io::Result<()> { + let event_handler = EventHandler::new(250); + + // Validate authentication on startup if we have a service client + if let Some(service) = &self.service { + if service.is_authenticated() { + self.validate_auth_async().await; + } + } + + // Auto-load Questions data on startup (initial view is Questions) + if self.content.current_view() == ContentView::Questions + && matches!(self.data.questions, LoadState::Idle) + { + let _ = self + .action_tx + .send(AppAction::LoadData(DataRequest::Questions)); + } + + while !self.should_quit { + // Process any pending actions + self.process_actions(); + + // Draw the UI + terminal.draw(|frame| self.draw(frame))?; + + // Handle events + match event_handler.next()? { + Event::Key(key) => self.handle_key(key.code, key.modifiers), + Event::Resize(_, _) => {} // Terminal will redraw automatically + Event::Tick => {} // Can be used for animations/updates + } + } + + Ok(()) + } + + /// Process pending actions from the action queue. + fn process_actions(&mut self) { + while let Ok(action) = self.action_rx.try_recv() { + self.handle_action(action); + } + } + + /// Validate authentication asynchronously. + async fn validate_auth_async(&mut self) { + if let Some(service) = &self.service { + match service.validate_auth().await { + Ok(user) => { + let display_name = user + .common_name + .clone() + .or_else(|| user.first_name.clone()) + .unwrap_or_else(|| user.email.clone()); + self.connection_status = ConnectionStatus::Connected(display_name.clone()); + self.status_bar + .set_message(format!("Connected as {}", display_name)); + self.data.current_user = Some(user); + } + Err(e) => { + self.connection_status = ConnectionStatus::Error(e.clone()); + self.status_bar.set_message(format!("Auth failed: {}", e)); + } + } + } + } + + /// Switch to a specific tab and update content view. + pub(crate) fn switch_to_tab(&mut self, tab: ActiveTab) { + self.active_tab = tab; + let view = match tab { + ActiveTab::Questions => ContentView::Questions, + ActiveTab::Collections => ContentView::Collections, + ActiveTab::Databases => ContentView::Databases, + }; + self.content.set_view(view); + + // Auto-load data when switching to a view with Idle state + match tab { + ActiveTab::Questions => { + if matches!(self.data.questions, LoadState::Idle) { + let _ = self + .action_tx + .send(AppAction::LoadData(DataRequest::Questions)); + } + } + ActiveTab::Collections => { + if matches!(self.data.collections, LoadState::Idle) { + let _ = self + .action_tx + .send(AppAction::LoadData(DataRequest::Collections)); + } + } + ActiveTab::Databases => { + if matches!(self.data.databases, LoadState::Idle) { + let _ = self + .action_tx + .send(AppAction::LoadData(DataRequest::Databases)); + } + } + } + + self.status_bar + .set_message(format!("Viewing: {}", tab.label())); + } + + // ========================================================================= + // Drawing + // ========================================================================= + + /// Draw the UI. + fn draw(&mut self, frame: &mut Frame) { + let size = frame.area(); + + // Create main layout: Header with tabs, Content (100% width), Status bar + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(HEADER_HEIGHT), + Constraint::Min(0), // Main content (100% width) + Constraint::Length(STATUS_BAR_HEIGHT), + ]) + .split(size); + + // Draw header with integrated tabs + self.draw_header_with_tabs(frame, main_chunks[0]); + + // Draw content panel (full width, always focused) + self.content.draw(frame, main_chunks[1], true); + + // Draw status bar + self.status_bar.draw(frame, main_chunks[2], false); + + // Draw help overlay if visible + if self.show_help { + HelpOverlay::render(frame, size); + } + + // Draw record detail overlay if visible + if self.show_record_detail { + if let Some(ref mut detail) = self.record_detail { + detail.render(frame, size); + } + } + } + + /// Draw the header with integrated tab bar. + fn draw_header_with_tabs(&self, frame: &mut Frame, area: Rect) { + // Build connection indicator + let connection_indicator = match &self.connection_status { + ConnectionStatus::Disconnected => { + Span::styled(" ○ ", Style::default().fg(Color::DarkGray)) + } + ConnectionStatus::Connecting => Span::styled(" ◐ ", Style::default().fg(Color::Yellow)), + ConnectionStatus::Connected(_) => { + Span::styled(" ● ", Style::default().fg(Color::Green)) + } + ConnectionStatus::Error(_) => Span::styled(" ✗ ", Style::default().fg(Color::Red)), + }; + + // Build tab bar + let tabs = [ + ActiveTab::Questions, + ActiveTab::Collections, + ActiveTab::Databases, + ]; + let mut tab_spans: Vec = vec![Span::raw(" ")]; + + for (i, tab) in tabs.iter().enumerate() { + let is_active = *tab == self.active_tab; + let style = if is_active { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD | Modifier::REVERSED) + } else { + Style::default().fg(Color::White) + }; + + // Add tab with number key hint + tab_spans.push(Span::styled(format!(" {} {} ", i + 1, tab.label()), style)); + tab_spans.push(Span::raw(" ")); + } + + // Add connection status at the end + tab_spans.push(Span::styled("│", Style::default().fg(Color::DarkGray))); + tab_spans.push(connection_indicator); + + let header = Paragraph::new(Line::from(tab_spans)).block( + Block::default() + .title(" mbr-tui ") + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ); + frame.render_widget(header, area); + } +} diff --git a/crates/mbr-tui/src/components/content/filter.rs b/crates/mbr-tui/src/components/content/filter.rs new file mode 100644 index 0000000..46b19be --- /dev/null +++ b/crates/mbr-tui/src/components/content/filter.rs @@ -0,0 +1,187 @@ +//! Filter functionality for query results. +//! +//! Provides filter modal handling with two-step workflow (column selection → text input), +//! case-insensitive contains matching, and integration with search results. + +use super::ContentPanel; +use super::types::SortOrder; + +impl ContentPanel { + /// Check if filter modal is active. + pub fn is_filter_mode_active(&self) -> bool { + self.filter_mode_active + } + + /// Open filter column selection modal. + pub fn open_filter_modal(&mut self) { + if let Some(ref result) = self.query_result { + if !result.columns.is_empty() { + self.filter_mode_active = true; + self.filter_modal_step = 0; // Start at column selection + // Start at currently filtered column or first column + self.filter_modal_selection = self.filter_column_index.unwrap_or(0); + } + } + } + + /// Close filter modal without applying. + pub fn close_filter_modal(&mut self) { + self.filter_mode_active = false; + self.filter_modal_step = 0; + // Don't clear filter_text here to preserve it for re-editing + } + + /// Move selection up in filter modal (column selection step). + pub fn filter_modal_up(&mut self) { + if self.filter_modal_step == 0 && self.filter_modal_selection > 0 { + self.filter_modal_selection -= 1; + } + } + + /// Move selection down in filter modal (column selection step). + pub fn filter_modal_down(&mut self) { + if self.filter_modal_step == 0 { + if let Some(ref result) = self.query_result { + if self.filter_modal_selection < result.columns.len().saturating_sub(1) { + self.filter_modal_selection += 1; + } + } + } + } + + /// Move to next step in filter modal (column selection → text input). + pub fn filter_modal_next_step(&mut self) { + if self.filter_modal_step == 0 { + self.filter_modal_step = 1; + // Pre-fill with existing filter text if same column + if self.filter_column_index != Some(self.filter_modal_selection) { + self.filter_text.clear(); + } + } + } + + /// Move to previous step in filter modal (text input → column selection). + pub fn filter_modal_prev_step(&mut self) { + if self.filter_modal_step == 1 { + self.filter_modal_step = 0; + } + } + + /// Handle character input in filter modal (text input step). + pub fn filter_modal_input_char(&mut self, c: char) { + if self.filter_modal_step == 1 { + self.filter_text.push(c); + } + } + + /// Handle backspace in filter modal (text input step). + pub fn filter_modal_delete_char(&mut self) { + if self.filter_modal_step == 1 { + self.filter_text.pop(); + } + } + + /// Apply filter on selected column with current filter text. + pub fn apply_filter(&mut self) { + let selected_col = self.filter_modal_selection; + + if self.filter_text.is_empty() { + // Empty filter text - clear filter + self.clear_filter(); + } else { + // Apply filter + self.filter_column_index = Some(selected_col); + self.update_filter_indices(); + // Re-apply sort on filtered data + if self.sort_order != SortOrder::None { + self.update_sort_indices(); + } + } + + // Close modal + self.filter_mode_active = false; + self.filter_modal_step = 0; + + // Reset to first page and first row after filter + self.result_page = 0; + self.result_table_state.select(Some(0)); + } + + /// Clear filter and restore all rows. + pub fn clear_filter(&mut self) { + self.filter_column_index = None; + self.filter_text.clear(); + self.filter_indices = None; + // Re-apply sort on full data + if self.sort_order != SortOrder::None { + self.update_sort_indices(); + } + } + + /// Update filter indices based on current filter column and text. + /// If search is active, filters within search results. + pub(super) fn update_filter_indices(&mut self) { + let col_idx = match self.filter_column_index { + Some(idx) => idx, + None => { + self.filter_indices = None; + return; + } + }; + + if self.filter_text.is_empty() { + self.filter_indices = None; + return; + } + + if let Some(ref result) = self.query_result { + if col_idx >= result.columns.len() { + self.filter_indices = None; + return; + } + + // Case-insensitive contains match + let filter_lower = self.filter_text.to_lowercase(); + + // Determine the base set of indices to filter from + let base_indices: Box> = + if let Some(ref search_indices) = self.result_search_indices { + // Filter within search results + Box::new(search_indices.iter().copied()) + } else { + // Filter all rows + Box::new(0..result.rows.len()) + }; + + let indices: Vec = base_indices + .filter(|&i| { + result + .rows + .get(i) + .and_then(|row| row.get(col_idx)) + .map(|cell| cell.to_lowercase().contains(&filter_lower)) + .unwrap_or(false) + }) + .collect(); + + self.filter_indices = Some(indices); + } + } + + /// Get current filter info for display. + /// Returns (column_name, filter_text, visible_row_count) if filter is active. + #[allow(dead_code)] // Designed for status bar display in future + pub fn get_filter_info(&self) -> Option<(String, String, usize)> { + if let (Some(col_idx), Some(result)) = (self.filter_column_index, &self.query_result) { + if col_idx < result.columns.len() && !self.filter_text.is_empty() { + let visible = self.visible_row_count(); + return Some(( + result.columns[col_idx].clone(), + self.filter_text.clone(), + visible, + )); + } + } + None + } +} diff --git a/crates/mbr-tui/src/components/content/key_handler.rs b/crates/mbr-tui/src/components/content/key_handler.rs new file mode 100644 index 0000000..3930f6e --- /dev/null +++ b/crates/mbr-tui/src/components/content/key_handler.rs @@ -0,0 +1,430 @@ +//! Key event handling for ContentPanel. +//! +//! Implements the Component trait's handle_key method with view-specific +//! key bindings for navigation, search, sort, filter, and modal interactions. + +use crossterm::event::{KeyCode, KeyEvent}; + +use super::ContentPanel; +use super::types::{ContentView, InputMode}; + +impl ContentPanel { + /// Handle key events for the content panel. + /// Returns true if the key was handled, false otherwise. + pub fn handle_key_event(&mut self, key: KeyEvent) -> bool { + // Search mode input handling (takes priority in Questions view) + if self.input_mode == InputMode::Search { + return self.handle_search_mode_key(key); + } + + // Delegate to view-specific handlers + match &self.view { + ContentView::Questions => self.handle_questions_key(key), + ContentView::Collections => self.handle_collections_key(key), + ContentView::Databases => self.handle_databases_key(key), + ContentView::QueryResult => self.handle_query_result_key(key), + ContentView::CollectionQuestions { .. } => self.handle_collection_questions_key(key), + ContentView::DatabaseSchemas { .. } => self.handle_database_schemas_key(key), + ContentView::SchemaTables { .. } => self.handle_schema_tables_key(key), + ContentView::TablePreview { .. } => self.handle_table_preview_key(key), + ContentView::Welcome => self.handle_welcome_key(key), + } + } + + /// Handle keys in search mode. + fn handle_search_mode_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char(c) => { + self.handle_search_input(c); + true + } + KeyCode::Backspace => { + self.handle_search_backspace(); + true + } + // Enter and Esc are handled by App (to send actions) + _ => false, + } + } + + /// Handle keys in Questions view. + fn handle_questions_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.select_previous(); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.select_next(); + true + } + KeyCode::Home | KeyCode::Char('g') => { + self.select_first(); + true + } + KeyCode::End | KeyCode::Char('G') => { + self.select_last(); + true + } + KeyCode::Char('/') => { + self.enter_search_mode(); + true + } + _ => false, + } + } + + /// Handle keys in Collections view. + fn handle_collections_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.select_collections_previous(); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.select_collections_next(); + true + } + KeyCode::Home | KeyCode::Char('g') => { + self.select_collections_first(); + true + } + KeyCode::End | KeyCode::Char('G') => { + self.select_collections_last(); + true + } + _ => false, + } + } + + /// Handle keys in Databases view. + fn handle_databases_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.select_databases_previous(); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.select_databases_next(); + true + } + KeyCode::Home | KeyCode::Char('g') => { + self.select_databases_first(); + true + } + KeyCode::End | KeyCode::Char('G') => { + self.select_databases_last(); + true + } + _ => false, + } + } + + /// Handle keys in CollectionQuestions view. + fn handle_collection_questions_key(&mut self, key: KeyEvent) -> bool { + // Same navigation as Questions, Enter/Esc handled by App + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.select_previous(); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.select_next(); + true + } + KeyCode::Home | KeyCode::Char('g') => { + self.select_first(); + true + } + KeyCode::End | KeyCode::Char('G') => { + self.select_last(); + true + } + _ => false, + } + } + + /// Handle keys in DatabaseSchemas view. + fn handle_database_schemas_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.select_schemas_previous(); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.select_schemas_next(); + true + } + KeyCode::Home | KeyCode::Char('g') => { + self.select_schemas_first(); + true + } + KeyCode::End | KeyCode::Char('G') => { + self.select_schemas_last(); + true + } + _ => false, + } + } + + /// Handle keys in SchemaTables view. + fn handle_schema_tables_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.select_tables_previous(); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.select_tables_next(); + true + } + KeyCode::Home | KeyCode::Char('g') => { + self.select_tables_first(); + true + } + KeyCode::End | KeyCode::Char('G') => { + self.select_tables_last(); + true + } + _ => false, + } + } + + /// Handle keys in QueryResult view. + fn handle_query_result_key(&mut self, key: KeyEvent) -> bool { + // Modal handlers take priority + if self.filter_mode_active { + return self.handle_filter_modal_key(key); + } + if self.sort_mode_active { + return self.handle_sort_modal_key(key); + } + if self.result_search_active { + return self.handle_result_search_key(key); + } + + // Normal result navigation + self.handle_result_navigation_key(key) + } + + /// Handle keys in TablePreview view (same as QueryResult). + fn handle_table_preview_key(&mut self, key: KeyEvent) -> bool { + // Modal handlers take priority + if self.filter_mode_active { + return self.handle_filter_modal_key(key); + } + if self.sort_mode_active { + return self.handle_sort_modal_key(key); + } + if self.result_search_active { + return self.handle_result_search_key(key); + } + + // Normal result navigation + self.handle_result_navigation_key(key) + } + + /// Handle keys in Welcome view. + fn handle_welcome_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.scroll.scroll_up(); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.scroll.scroll_down(); + true + } + KeyCode::PageUp => { + for _ in 0..self.scroll.visible { + self.scroll.scroll_up(); + } + true + } + KeyCode::PageDown => { + for _ in 0..self.scroll.visible { + self.scroll.scroll_down(); + } + true + } + _ => false, + } + } + + /// Handle keys in filter modal. + fn handle_filter_modal_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.filter_modal_up(); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.filter_modal_down(); + true + } + KeyCode::Enter => { + if self.filter_modal_step == 0 { + // Column selection → text input + self.filter_modal_next_step(); + } else { + // Text input → apply filter + self.apply_filter(); + } + true + } + KeyCode::Esc => { + if self.filter_modal_step == 1 { + // Text input → column selection + self.filter_modal_prev_step(); + } else { + // Column selection → close modal + self.close_filter_modal(); + } + true + } + KeyCode::Char('f') if self.filter_modal_step == 0 => { + // Close modal if 'f' pressed in column selection + self.close_filter_modal(); + true + } + KeyCode::Backspace => { + self.filter_modal_delete_char(); + true + } + KeyCode::Char(c) if self.filter_modal_step == 1 => { + self.filter_modal_input_char(c); + true + } + _ => false, + } + } + + /// Handle keys in sort modal. + fn handle_sort_modal_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.sort_modal_up(); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.sort_modal_down(); + true + } + KeyCode::Enter => { + self.apply_sort(); + true + } + KeyCode::Esc | KeyCode::Char('s') => { + self.close_sort_modal(); + true + } + _ => false, + } + } + + /// Handle keys in result search mode. + fn handle_result_search_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Char(c) => { + self.result_search_input_char(c); + true + } + KeyCode::Backspace => { + self.result_search_delete_char(); + true + } + KeyCode::Enter => { + self.apply_result_search(); + true + } + KeyCode::Esc => { + self.close_result_search(); + true + } + _ => false, + } + } + + /// Handle result table navigation keys. + fn handle_result_navigation_key(&mut self, key: KeyEvent) -> bool { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.select_result_previous(); + true + } + KeyCode::Down | KeyCode::Char('j') => { + self.select_result_next(); + true + } + // Horizontal scroll with h/l or Left/Right arrows + KeyCode::Left | KeyCode::Char('h') => { + self.scroll_left(); + true + } + KeyCode::Right | KeyCode::Char('l') => { + self.scroll_right(); + true + } + // Pagination: n for next page, p for previous page (matches CLI) + KeyCode::Char('n') => { + self.next_page(); + true + } + KeyCode::Char('p') => { + self.prev_page(); + true + } + // PageUp/PageDown for scrolling within page (matches CLI) + KeyCode::PageUp => { + self.scroll_result_page_up(); + true + } + KeyCode::PageDown => { + self.scroll_result_page_down(); + true + } + // First/Last page with g/G + KeyCode::Home | KeyCode::Char('g') => { + self.first_page(); + true + } + KeyCode::End | KeyCode::Char('G') => { + self.last_page(); + true + } + // Sort: s to open sort modal + KeyCode::Char('s') => { + self.open_sort_modal(); + true + } + // Filter: f to open filter modal + KeyCode::Char('f') => { + self.open_filter_modal(); + true + } + // Clear filter: F (shift+f) to clear filter + KeyCode::Char('F') => { + self.clear_filter(); + // Reset to first page after clearing filter + self.result_page = 0; + self.result_table_state.select(Some(0)); + true + } + // Search: / to open search input + KeyCode::Char('/') => { + self.open_result_search(); + true + } + // Clear search: Shift+S to clear search + KeyCode::Char('S') => { + self.clear_result_search(); + // Reset to first page after clearing search + self.result_page = 0; + self.result_table_state.select(Some(0)); + true + } + // Note: Esc is handled in App for returning to previous view + _ => false, + } + } +} diff --git a/crates/mbr-tui/src/components/content/mod.rs b/crates/mbr-tui/src/components/content/mod.rs index f752edb..5ce0b8f 100644 --- a/crates/mbr-tui/src/components/content/mod.rs +++ b/crates/mbr-tui/src/components/content/mod.rs @@ -3,170 +3,117 @@ //! Displays the main content area (query results, question details, etc.). //! //! This module is split into submodules for better organization: +//! - `types`: View types, input modes, and data structures //! - `views`: View rendering functions (Welcome, Questions, Collections, etc.) //! - `modals`: Modal overlay rendering (Sort, Filter, Search) - +//! - `navigation`: Navigation stack and view transitions +//! - `selection`: Selection navigation for each view type +//! - `search`: Search mode handling +//! - `sort`: Sort functionality for query results +//! - `filter`: Filter functionality for query results +//! - `result_search`: All-column search in results +//! - `pagination`: Pagination and scrolling +//! - `key_handler`: Key event handling + +mod filter; +mod key_handler; mod modals; +mod navigation; +mod pagination; +mod result_search; +mod search; +mod selection; +mod sort; +pub mod types; mod views; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::KeyEvent; use ratatui::{Frame, layout::Rect, widgets::TableState}; use mbr_core::api::models::{CollectionItem, Database, Question, TableInfo}; +pub use types::{ContentView, InputMode, QueryResultData, SortOrder}; + use super::{Component, ScrollState}; use crate::service::LoadState; - -/// Input mode for text input fields. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum InputMode { - /// Normal navigation mode - #[default] - Normal, - /// Search input mode - Search, -} - -/// Sort order for query results. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum SortOrder { - /// No sorting applied - #[default] - None, - /// Ascending order (A-Z, 0-9) - Ascending, - /// Descending order (Z-A, 9-0) - Descending, -} - -/// Content view types with embedded navigation context. -/// -/// Views that represent drill-down navigation carry their context data directly, -/// eliminating the need for separate context fields and ensuring consistency -/// between the navigation stack and the current state. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum ContentView { - #[default] - Welcome, - Questions, - Collections, - Databases, - QueryResult, - /// Questions filtered by a specific collection (id, name) - CollectionQuestions { - id: u32, - name: String, - }, - /// Schemas in a specific database (db_id, db_name) - DatabaseSchemas { - db_id: u32, - db_name: String, - }, - /// Tables in a specific schema (db_id, schema_name) - SchemaTables { - db_id: u32, - schema_name: String, - }, - /// Table data preview (db_id, table_id, table_name) - TablePreview { - db_id: u32, - table_id: u32, - table_name: String, - }, -} - -/// Query result data for display in TUI. -#[derive(Debug, Clone, PartialEq)] -pub struct QueryResultData { - /// Question ID that was executed - pub question_id: u32, - /// Question name for display - pub question_name: String, - /// Column headers - pub columns: Vec, - /// Row data (each cell as string) - pub rows: Vec>, -} - -/// Default rows per page for query result pagination. -const DEFAULT_ROWS_PER_PAGE: usize = 100; +use types::DEFAULT_ROWS_PER_PAGE; /// Content panel showing main content. pub struct ContentPanel { - view: ContentView, - scroll: ScrollState, + pub(super) view: ContentView, + pub(super) scroll: ScrollState, /// Horizontal scroll offset (column index) - scroll_x: usize, + pub(super) scroll_x: usize, /// Questions data for the Questions view - questions: LoadState>, + pub(super) questions: LoadState>, /// Table state for Questions view (manages selection and scroll) - table_state: TableState, + pub(super) table_state: TableState, /// Collections data for the Collections view - collections: LoadState>, + pub(super) collections: LoadState>, /// Table state for Collections view - collections_table_state: TableState, + pub(super) collections_table_state: TableState, /// Databases data for the Databases view - databases: LoadState>, + pub(super) databases: LoadState>, /// Table state for Databases view - databases_table_state: TableState, + pub(super) databases_table_state: TableState, /// Schemas data for the DatabaseSchemas view - schemas: LoadState>, + pub(super) schemas: LoadState>, /// Table state for Schemas view - schemas_table_state: TableState, + pub(super) schemas_table_state: TableState, /// Tables data for the SchemaTables view - tables: LoadState>, + pub(super) tables: LoadState>, /// Table state for Tables view - tables_table_state: TableState, + pub(super) tables_table_state: TableState, /// Query result data for QueryResult view - query_result: Option, + pub(super) query_result: Option, /// Sorted row indices (None = original order, Some = sorted indices) /// Using indices instead of copying rows for memory efficiency - sort_indices: Option>, + pub(super) sort_indices: Option>, /// Table state for query result table - result_table_state: TableState, + pub(super) result_table_state: TableState, /// Current page for query result pagination (0-indexed) - result_page: usize, + pub(super) result_page: usize, /// Rows per page for query result pagination - rows_per_page: usize, + pub(super) rows_per_page: usize, /// Current input mode - input_mode: InputMode, + pub(super) input_mode: InputMode, /// Current search query - search_query: String, + pub(super) search_query: String, /// Active search query (used for display after search is executed) - active_search: Option, + pub(super) active_search: Option, /// Navigation stack for multi-level drill-down (supports 4+ levels) /// Context data is now embedded directly in ContentView variants. /// Used for: Databases → Schemas → Tables → Preview /// Collections → Questions → QueryResult - navigation_stack: Vec, + pub(super) navigation_stack: Vec, /// Sort order for query results - sort_order: SortOrder, + pub(super) sort_order: SortOrder, /// Column index currently being sorted (None = no sort) - sort_column_index: Option, + pub(super) sort_column_index: Option, /// Whether sort column selection modal is active - sort_mode_active: bool, + pub(super) sort_mode_active: bool, /// Selected column index in sort modal - sort_modal_selection: usize, + pub(super) sort_modal_selection: usize, // === Filter state === /// Filtered row indices (None = no filter, Some = filtered indices) - filter_indices: Option>, + pub(super) filter_indices: Option>, /// Column index currently being filtered (None = no filter) - filter_column_index: Option, + pub(super) filter_column_index: Option, /// Filter text (case-insensitive contains match) - filter_text: String, + pub(super) filter_text: String, /// Whether filter modal is active - filter_mode_active: bool, + pub(super) filter_mode_active: bool, /// Current step in filter modal (0 = column selection, 1 = text input) - filter_modal_step: usize, + pub(super) filter_modal_step: usize, /// Selected column index in filter modal - filter_modal_selection: usize, + pub(super) filter_modal_selection: usize, // === Result Search state (all-column search) === /// Whether result search mode is active - result_search_active: bool, + pub(super) result_search_active: bool, /// Search text for result search (all-column, case-insensitive) - result_search_text: String, + pub(super) result_search_text: String, /// Searched row indices (None = no search, Some = matched indices) - result_search_indices: Option>, + pub(super) result_search_indices: Option>, } impl Default for ContentPanel { @@ -274,1314 +221,6 @@ impl ContentPanel { ContentView::QueryResult | ContentView::TablePreview { .. } ) } - - // === Navigation Stack Methods === - - /// Push current view to stack and navigate to new view. - /// Used for drill-down navigation (e.g., Collections → Questions). - pub fn push_view(&mut self, new_view: ContentView) { - self.navigation_stack.push(self.view.clone()); - self.view = new_view; - } - - /// Pop from navigation stack and return to previous view. - /// Returns the view that was popped to, or None if stack was empty. - pub fn pop_view(&mut self) -> Option { - if let Some(previous) = self.navigation_stack.pop() { - self.view = previous.clone(); - Some(previous) - } else { - None - } - } - - /// Get the depth of the navigation stack. - #[allow(dead_code)] // Useful for debugging and future features - pub fn navigation_depth(&self) -> usize { - self.navigation_stack.len() - } - - /// Clear the navigation stack (used when switching tabs). - pub fn clear_navigation_stack(&mut self) { - self.navigation_stack.clear(); - } - - /// Update questions data from AppData. - /// Automatically selects first item when data is loaded. - pub fn update_questions(&mut self, questions: &LoadState>) { - self.questions = questions.clone(); - - // Auto-select first item when data is loaded - if let LoadState::Loaded(items) = questions { - if !items.is_empty() && self.table_state.selected().is_none() { - self.table_state.select(Some(0)); - } - } - } - - /// Update collections data from AppData. - /// Automatically selects first item when data is loaded. - pub fn update_collections(&mut self, collections: &LoadState>) { - self.collections = collections.clone(); - - // Auto-select first item when data is loaded - if let LoadState::Loaded(items) = collections { - if !items.is_empty() && self.collections_table_state.selected().is_none() { - self.collections_table_state.select(Some(0)); - } - } - } - - /// Update databases data from AppData. - /// Automatically selects first item when data is loaded. - pub fn update_databases(&mut self, databases: &LoadState>) { - self.databases = databases.clone(); - - // Auto-select first item when data is loaded - if let LoadState::Loaded(items) = databases { - if !items.is_empty() && self.databases_table_state.selected().is_none() { - self.databases_table_state.select(Some(0)); - } - } - } - - /// Update schemas data from AppData. - /// Automatically selects first item when data is loaded. - pub fn update_schemas(&mut self, schemas: &LoadState>) { - self.schemas = schemas.clone(); - - // Auto-select first item when data is loaded - if let LoadState::Loaded(items) = schemas { - if !items.is_empty() && self.schemas_table_state.selected().is_none() { - self.schemas_table_state.select(Some(0)); - } - } - } - - /// Update tables data from AppData. - /// Automatically selects first item when data is loaded. - pub fn update_tables(&mut self, tables: &LoadState>) { - self.tables = tables.clone(); - - // Auto-select first item when data is loaded - if let LoadState::Loaded(items) = tables { - if !items.is_empty() && self.tables_table_state.selected().is_none() { - self.tables_table_state.select(Some(0)); - } - } - } - - /// Select next question in list. - pub fn select_next(&mut self) { - if let LoadState::Loaded(questions) = &self.questions { - if questions.is_empty() { - return; - } - let current = self.table_state.selected().unwrap_or(0); - let next = (current + 1).min(questions.len() - 1); - self.table_state.select(Some(next)); - } - } - - /// Select previous question in list. - pub fn select_previous(&mut self) { - let current = self.table_state.selected().unwrap_or(0); - let prev = current.saturating_sub(1); - self.table_state.select(Some(prev)); - } - - /// Select first question in list. - pub fn select_first(&mut self) { - self.table_state.select(Some(0)); - } - - /// Select last question in list. - pub fn select_last(&mut self) { - if let LoadState::Loaded(questions) = &self.questions { - if !questions.is_empty() { - self.table_state.select(Some(questions.len() - 1)); - } - } - } - - // === Collections view navigation === - - /// Select next collection in list. - fn select_collections_next(&mut self) { - if let LoadState::Loaded(collections) = &self.collections { - if collections.is_empty() { - return; - } - let current = self.collections_table_state.selected().unwrap_or(0); - let next = (current + 1).min(collections.len() - 1); - self.collections_table_state.select(Some(next)); - } - } - - /// Select previous collection in list. - fn select_collections_previous(&mut self) { - let current = self.collections_table_state.selected().unwrap_or(0); - let prev = current.saturating_sub(1); - self.collections_table_state.select(Some(prev)); - } - - /// Select first collection in list. - fn select_collections_first(&mut self) { - self.collections_table_state.select(Some(0)); - } - - /// Select last collection in list. - fn select_collections_last(&mut self) { - if let LoadState::Loaded(collections) = &self.collections { - if !collections.is_empty() { - self.collections_table_state - .select(Some(collections.len() - 1)); - } - } - } - - // === Databases view navigation === - - /// Select next database in list. - fn select_databases_next(&mut self) { - if let LoadState::Loaded(databases) = &self.databases { - if databases.is_empty() { - return; - } - let current = self.databases_table_state.selected().unwrap_or(0); - let next = (current + 1).min(databases.len() - 1); - self.databases_table_state.select(Some(next)); - } - } - - /// Select previous database in list. - fn select_databases_previous(&mut self) { - let current = self.databases_table_state.selected().unwrap_or(0); - let prev = current.saturating_sub(1); - self.databases_table_state.select(Some(prev)); - } - - /// Select first database in list. - fn select_databases_first(&mut self) { - self.databases_table_state.select(Some(0)); - } - - /// Select last database in list. - fn select_databases_last(&mut self) { - if let LoadState::Loaded(databases) = &self.databases { - if !databases.is_empty() { - self.databases_table_state.select(Some(databases.len() - 1)); - } - } - } - - // === Schemas view navigation === - - /// Select next schema in list. - fn select_schemas_next(&mut self) { - if let LoadState::Loaded(schemas) = &self.schemas { - if schemas.is_empty() { - return; - } - let current = self.schemas_table_state.selected().unwrap_or(0); - let next = (current + 1).min(schemas.len() - 1); - self.schemas_table_state.select(Some(next)); - } - } - - /// Select previous schema in list. - fn select_schemas_previous(&mut self) { - let current = self.schemas_table_state.selected().unwrap_or(0); - let prev = current.saturating_sub(1); - self.schemas_table_state.select(Some(prev)); - } - - /// Select first schema in list. - fn select_schemas_first(&mut self) { - self.schemas_table_state.select(Some(0)); - } - - /// Select last schema in list. - fn select_schemas_last(&mut self) { - if let LoadState::Loaded(schemas) = &self.schemas { - if !schemas.is_empty() { - self.schemas_table_state.select(Some(schemas.len() - 1)); - } - } - } - - // === Tables view navigation === - - /// Select next table in list. - fn select_tables_next(&mut self) { - if let LoadState::Loaded(tables) = &self.tables { - if tables.is_empty() { - return; - } - let current = self.tables_table_state.selected().unwrap_or(0); - let next = (current + 1).min(tables.len() - 1); - self.tables_table_state.select(Some(next)); - } - } - - /// Select previous table in list. - fn select_tables_previous(&mut self) { - let current = self.tables_table_state.selected().unwrap_or(0); - let prev = current.saturating_sub(1); - self.tables_table_state.select(Some(prev)); - } - - /// Select first table in list. - fn select_tables_first(&mut self) { - self.tables_table_state.select(Some(0)); - } - - /// Select last table in list. - fn select_tables_last(&mut self) { - if let LoadState::Loaded(tables) = &self.tables { - if !tables.is_empty() { - self.tables_table_state.select(Some(tables.len() - 1)); - } - } - } - - /// Get the currently selected question ID. - /// Works in both Questions and CollectionQuestions views. - pub fn get_selected_question_id(&self) -> Option { - if !self.is_questions_view() { - return None; - } - if let LoadState::Loaded(questions) = &self.questions { - if let Some(selected) = self.table_state.selected() { - return questions.get(selected).map(|q| q.id); - } - } - None - } - - /// Get the currently selected collection info (id, name). - pub fn get_selected_collection_info(&self) -> Option<(u32, String)> { - if self.view != ContentView::Collections { - return None; - } - if let LoadState::Loaded(collections) = &self.collections { - if let Some(selected) = self.collections_table_state.selected() { - return collections - .get(selected) - .and_then(|c| c.id.map(|id| (id, c.name.clone()))); - } - } - None - } - - // === Collection Questions View === - - /// Enter collection questions view to show questions from a specific collection. - /// Uses navigation stack for proper back navigation. - pub fn enter_collection_questions(&mut self, collection_id: u32, collection_name: String) { - // Reset questions state for new load - self.questions = LoadState::Idle; - self.table_state = TableState::default(); - // Push new view with embedded context - self.push_view(ContentView::CollectionQuestions { - id: collection_id, - name: collection_name, - }); - } - - /// Exit collection questions view and return to previous view. - /// Uses navigation stack to return to the correct originating view. - pub fn exit_collection_questions(&mut self) { - // Reset questions state - self.questions = LoadState::Idle; - self.table_state = TableState::default(); - // Pop from navigation stack (defaults to Collections if stack is empty) - if self.pop_view().is_none() { - self.view = ContentView::Collections; - } - } - - /// Get the current collection context (id, name) for CollectionQuestions view. - /// Extracts context from the ContentView variant. - #[allow(dead_code)] // Designed for future features - pub fn get_collection_context(&self) -> Option<(u32, String)> { - match &self.view { - ContentView::CollectionQuestions { id, name } => Some((*id, name.clone())), - _ => None, - } - } - - // === Database Drill-down View === - - /// Get the currently selected database info (id, name). - pub fn get_selected_database_info(&self) -> Option<(u32, String)> { - if self.view != ContentView::Databases { - return None; - } - if let LoadState::Loaded(databases) = &self.databases { - if let Some(selected) = self.databases_table_state.selected() { - return databases.get(selected).map(|db| (db.id, db.name.clone())); - } - } - None - } - - /// Get the currently selected schema name. - pub fn get_selected_schema(&self) -> Option { - if !self.is_database_schemas_view() { - return None; - } - if let LoadState::Loaded(schemas) = &self.schemas { - if let Some(selected) = self.schemas_table_state.selected() { - return schemas.get(selected).cloned(); - } - } - None - } - - /// Get the currently selected table info (table_id, table_name). - pub fn get_selected_table_info(&self) -> Option<(u32, String)> { - if !self.is_schema_tables_view() { - return None; - } - if let LoadState::Loaded(tables) = &self.tables { - if let Some(selected) = self.tables_table_state.selected() { - return tables.get(selected).map(|t| (t.id, t.name.clone())); - } - } - None - } - - /// Enter database schemas view to show schemas in a specific database. - /// Uses navigation stack for proper back navigation. - pub fn enter_database_schemas(&mut self, database_id: u32, database_name: String) { - // Reset schemas state for new load - self.schemas = LoadState::Idle; - self.schemas_table_state = TableState::default(); - // Push new view with embedded context - self.push_view(ContentView::DatabaseSchemas { - db_id: database_id, - db_name: database_name, - }); - } - - /// Exit database schemas view and return to previous view. - pub fn exit_database_schemas(&mut self) { - self.schemas = LoadState::Idle; - self.schemas_table_state = TableState::default(); - // Pop from navigation stack (defaults to Databases if stack is empty) - if self.pop_view().is_none() { - self.view = ContentView::Databases; - } - } - - /// Enter schema tables view to show tables in a specific schema. - /// Uses navigation stack for proper back navigation. - pub fn enter_schema_tables(&mut self, database_id: u32, schema_name: String) { - // Reset tables state for new load - self.tables = LoadState::Idle; - self.tables_table_state = TableState::default(); - // Push new view with embedded context - self.push_view(ContentView::SchemaTables { - db_id: database_id, - schema_name, - }); - } - - /// Exit schema tables view and return to previous view. - pub fn exit_schema_tables(&mut self) { - self.tables = LoadState::Idle; - self.tables_table_state = TableState::default(); - // Pop from navigation stack (defaults to DatabaseSchemas if stack is empty) - if self.pop_view().is_none() { - // Fallback without context - should rarely happen - self.view = ContentView::Databases; - } - } - - /// Enter table preview view to show sample data from a table. - /// Uses navigation stack for proper back navigation. - pub fn enter_table_preview(&mut self, database_id: u32, table_id: u32, table_name: String) { - // Reset query result for new load - self.query_result = None; - self.sort_indices = None; - self.filter_indices = None; - self.result_table_state = TableState::default(); - self.result_page = 0; - self.scroll_x = 0; - // Reset sort/filter state - self.reset_sort_filter_state(); - // Push new view with embedded context - self.push_view(ContentView::TablePreview { - db_id: database_id, - table_id, - table_name, - }); - } - - /// Exit table preview view and return to previous view. - pub fn exit_table_preview(&mut self) { - self.query_result = None; - self.sort_indices = None; - self.filter_indices = None; - self.result_table_state = TableState::default(); - self.result_page = 0; - self.scroll_x = 0; - // Reset sort/filter state - self.reset_sort_filter_state(); - // Pop from navigation stack (defaults to SchemaTables if stack is empty) - if self.pop_view().is_none() { - // Fallback without context - should rarely happen - self.view = ContentView::Databases; - } - } - - /// Set table preview data (used when data is loaded after entering preview view). - /// Does not change navigation state since enter_table_preview already handled that. - pub fn set_table_preview_data(&mut self, data: QueryResultData) { - // Clear sort/filter indices for new data - self.sort_indices = None; - self.filter_indices = None; - self.query_result = Some(data); - self.result_table_state = TableState::default(); - self.result_page = 0; - // Reset sort/filter state for new data - self.reset_sort_filter_state(); - // Auto-select first row if available - if self - .query_result - .as_ref() - .is_some_and(|r| !r.rows.is_empty()) - { - self.result_table_state.select(Some(0)); - } - } - - /// Get the current database context (db_id, db_name) for DatabaseSchemas view. - /// Extracts context from the ContentView variant. - pub fn get_database_context(&self) -> Option<(u32, String)> { - match &self.view { - ContentView::DatabaseSchemas { db_id, db_name } => Some((*db_id, db_name.clone())), - ContentView::SchemaTables { db_id, .. } => { - // Also available in SchemaTables since we're drilling down from DatabaseSchemas - // Need to look at navigation stack for the db_name - for view in self.navigation_stack.iter().rev() { - if let ContentView::DatabaseSchemas { db_id: id, db_name } = view { - if *id == *db_id { - return Some((*id, db_name.clone())); - } - } - } - None - } - ContentView::TablePreview { db_id, .. } => { - // Look at navigation stack for database context - for view in self.navigation_stack.iter().rev() { - if let ContentView::DatabaseSchemas { db_id: id, db_name } = view { - if *id == *db_id { - return Some((*id, db_name.clone())); - } - } - } - None - } - _ => None, - } - } - - /// Get the current schema context (db_id, schema_name) for SchemaTables view. - /// Extracts context from the ContentView variant. - pub fn get_schema_context(&self) -> Option<(u32, String)> { - match &self.view { - ContentView::SchemaTables { db_id, schema_name } => Some((*db_id, schema_name.clone())), - ContentView::TablePreview { db_id, .. } => { - // Look at navigation stack for schema context - for view in self.navigation_stack.iter().rev() { - if let ContentView::SchemaTables { - db_id: id, - schema_name, - } = view - { - if *id == *db_id { - return Some((*id, schema_name.clone())); - } - } - } - None - } - _ => None, - } - } - - /// Get the current table context (db_id, table_id, table_name) for TablePreview view. - /// Extracts context from the ContentView variant. - #[allow(dead_code)] // Designed for future features - pub fn get_table_context(&self) -> Option<(u32, u32, String)> { - match &self.view { - ContentView::TablePreview { - db_id, - table_id, - table_name, - } => Some((*db_id, *table_id, table_name.clone())), - _ => None, - } - } - - // === Search functionality === - - /// Get the current input mode. - pub fn input_mode(&self) -> InputMode { - self.input_mode - } - - /// Enter search mode. - pub fn enter_search_mode(&mut self) { - self.input_mode = InputMode::Search; - self.search_query.clear(); - } - - /// Exit search mode without executing search. - pub fn exit_search_mode(&mut self) { - self.input_mode = InputMode::Normal; - self.search_query.clear(); - } - - /// Get the current search query (for debugging/future use). - #[allow(dead_code)] - pub fn get_search_query(&self) -> &str { - &self.search_query - } - - /// Get the active search query (after execution). - pub fn get_active_search(&self) -> Option<&str> { - self.active_search.as_deref() - } - - /// Execute the current search query and return it for API call. - /// Returns Some(query) if there's a query to search, None if empty. - pub fn execute_search(&mut self) -> Option { - self.input_mode = InputMode::Normal; - let query = self.search_query.trim().to_string(); - if query.is_empty() { - self.active_search = None; - None - } else { - self.active_search = Some(query.clone()); - // Reset selection for new results - self.table_state.select(Some(0)); - Some(query) - } - } - - /// Clear the active search and return to showing all questions. - pub fn clear_search(&mut self) { - self.active_search = None; - self.search_query.clear(); - self.table_state.select(Some(0)); - } - - /// Handle character input in search mode. - pub fn handle_search_input(&mut self, c: char) { - self.search_query.push(c); - } - - /// Handle backspace in search mode. - pub fn handle_search_backspace(&mut self) { - self.search_query.pop(); - } - - /// Get the currently selected record in QueryResult or TablePreview view. - /// Returns (columns, values) tuple for the selected row. - /// Respects sort order when sorting is active. - pub fn get_selected_record(&self) -> Option<(Vec, Vec)> { - if !self.is_result_view() { - return None; - } - if let Some(ref result) = self.query_result { - if let Some(selected) = self.result_table_state.selected() { - // Calculate logical index considering pagination - let page_start = self.result_page * self.rows_per_page; - let logical_index = page_start + selected; - - // Get row using sorted index if sorting is active - if let Some(row) = self.get_visible_row(logical_index) { - return Some((result.columns.clone(), row.clone())); - } - } - } - None - } - - /// Set query result data and switch to QueryResult view. - /// Uses navigation stack to enable returning to the originating view. - pub fn set_query_result(&mut self, data: QueryResultData) { - // Clear sort/filter indices for new data - self.sort_indices = None; - self.filter_indices = None; - self.query_result = Some(data); - self.result_table_state = TableState::default(); - self.result_page = 0; // Reset to first page - self.scroll_x = 0; - // Reset sort/filter state for new data - self.reset_sort_filter_state(); - // Auto-select first row if available - if self - .query_result - .as_ref() - .is_some_and(|r| !r.rows.is_empty()) - { - self.result_table_state.select(Some(0)); - } - // Push current view to stack before switching - self.push_view(ContentView::QueryResult); - } - - /// Clear query result and return to previous view. - /// Uses navigation stack to return to the correct originating view. - pub fn back_to_questions(&mut self) { - self.query_result = None; - self.sort_indices = None; - self.filter_indices = None; - self.result_table_state = TableState::default(); - self.result_page = 0; - self.scroll_x = 0; - // Reset sort/filter state - self.reset_sort_filter_state(); - // Pop from navigation stack (defaults to Questions if stack is empty) - if self.pop_view().is_none() { - self.view = ContentView::Questions; - } - } - - /// Reset sort, filter, and search state (helper for view transitions). - fn reset_sort_filter_state(&mut self) { - // Sort state - self.sort_order = SortOrder::None; - self.sort_column_index = None; - self.sort_mode_active = false; - // Filter state - self.filter_column_index = None; - self.filter_text.clear(); - self.filter_mode_active = false; - self.filter_modal_step = 0; - // Result search state - self.result_search_active = false; - self.result_search_text.clear(); - self.result_search_indices = None; - } - - /// Get the number of visible rows (after search and filter are applied). - /// - /// Priority: Search first, then Filter. Both are applied if both active. - fn visible_row_count(&self) -> usize { - match (&self.result_search_indices, &self.filter_indices) { - // Both search and filter: use filter (which operates on search results) - (Some(_), Some(filter)) => filter.len(), - // Only filter: use filter indices - (None, Some(filter)) => filter.len(), - // Only search: use search indices - (Some(search), None) => search.len(), - // Neither: use all rows - (None, None) => self - .query_result - .as_ref() - .map(|r| r.rows.len()) - .unwrap_or(0), - } - } - - /// Get total number of pages for query result (considers filter). - fn total_pages(&self) -> usize { - self.visible_row_count().div_ceil(self.rows_per_page) - } - - /// Go to next page in query result. - fn next_page(&mut self) { - let total = self.total_pages(); - if total > 0 && self.result_page < total - 1 { - self.result_page += 1; - self.result_table_state.select(Some(0)); // Reset selection to first row of new page - } - } - - /// Go to previous page in query result. - fn prev_page(&mut self) { - if self.result_page > 0 { - self.result_page -= 1; - self.result_table_state.select(Some(0)); // Reset selection to first row of new page - } - } - - /// Go to first page in query result. - fn first_page(&mut self) { - self.result_page = 0; - self.result_table_state.select(Some(0)); - } - - /// Go to last page in query result. - fn last_page(&mut self) { - let total = self.total_pages(); - if total > 0 { - self.result_page = total - 1; - self.result_table_state.select(Some(0)); - } - } - - /// Scroll left (show previous columns). - fn scroll_left(&mut self) { - self.scroll_x = self.scroll_x.saturating_sub(1); - } - - /// Scroll right (show next columns). - fn scroll_right(&mut self) { - let total_cols = self.get_total_columns(); - if total_cols > 0 && self.scroll_x < total_cols.saturating_sub(1) { - self.scroll_x += 1; - } - } - - /// Get total number of columns for current view. - fn get_total_columns(&self) -> usize { - match self.view { - ContentView::QueryResult => self - .query_result - .as_ref() - .map(|r| r.columns.len()) - .unwrap_or(0), - ContentView::Questions => 3, // ID, Name, Collection - _ => 0, - } - } - - /// Navigate result table. - fn select_result_next(&mut self) { - if let Some(ref result) = self.query_result { - if result.rows.is_empty() { - return; - } - let current = self.result_table_state.selected().unwrap_or(0); - let next = (current + 1).min(result.rows.len() - 1); - self.result_table_state.select(Some(next)); - } - } - - fn select_result_previous(&mut self) { - let current = self.result_table_state.selected().unwrap_or(0); - let prev = current.saturating_sub(1); - self.result_table_state.select(Some(prev)); - } - - /// Get the number of rows in the current page. - fn current_page_row_count(&self) -> usize { - self.query_result - .as_ref() - .map(|r| { - let total_rows = r.rows.len(); - let page_start = self.result_page * self.rows_per_page; - let page_end = (page_start + self.rows_per_page).min(total_rows); - page_end - page_start - }) - .unwrap_or(0) - } - - // === Sort functionality === - - /// Check if sort modal is active. - pub fn is_sort_mode_active(&self) -> bool { - self.sort_mode_active - } - - /// Open sort column selection modal. - pub fn open_sort_modal(&mut self) { - if let Some(ref result) = self.query_result { - if !result.columns.is_empty() { - self.sort_mode_active = true; - // Start at currently sorted column or first column - self.sort_modal_selection = self.sort_column_index.unwrap_or(0); - } - } - } - - /// Close sort modal without applying. - pub fn close_sort_modal(&mut self) { - self.sort_mode_active = false; - } - - /// Move selection up in sort modal. - pub fn sort_modal_up(&mut self) { - if self.sort_modal_selection > 0 { - self.sort_modal_selection -= 1; - } - } - - /// Move selection down in sort modal. - pub fn sort_modal_down(&mut self) { - if let Some(ref result) = self.query_result { - if self.sort_modal_selection < result.columns.len().saturating_sub(1) { - self.sort_modal_selection += 1; - } - } - } - - /// Apply sort on selected column. - /// If same column is selected, toggles between Ascending -> Descending -> None. - pub fn apply_sort(&mut self) { - let selected_col = self.sort_modal_selection; - - // Toggle sort order - if self.sort_column_index == Some(selected_col) { - // Same column - cycle through orders - self.sort_order = match self.sort_order { - SortOrder::None => SortOrder::Ascending, - SortOrder::Ascending => SortOrder::Descending, - SortOrder::Descending => SortOrder::None, - }; - if self.sort_order == SortOrder::None { - // Restore original order by clearing indices - self.sort_column_index = None; - self.sort_indices = None; - } else { - // Sort indices (not data) - self.update_sort_indices(); - } - } else { - // New column - start with ascending - self.sort_column_index = Some(selected_col); - self.sort_order = SortOrder::Ascending; - // Sort indices (not data) - self.update_sort_indices(); - } - - // Close modal - self.sort_mode_active = false; - - // Reset to first page and first row after sort - self.result_page = 0; - self.result_table_state.select(Some(0)); - } - - /// Clear sort and restore original order. - #[allow(dead_code)] // Designed for future features - pub fn clear_sort(&mut self) { - self.sort_order = SortOrder::None; - self.sort_column_index = None; - self.sort_indices = None; - } - - /// Update sort indices based on current sort column and order. - /// Uses index-based sorting for memory efficiency (no data cloning). - fn update_sort_indices(&mut self) { - if self.sort_order == SortOrder::None { - self.sort_indices = None; - return; - } - - let col_idx = match self.sort_column_index { - Some(idx) => idx, - None => { - self.sort_indices = None; - return; - } - }; - - if let Some(ref result) = self.query_result { - if col_idx >= result.columns.len() { - self.sort_indices = None; - return; - } - - // Create index array based on filtered or full row count - let row_count = self.visible_row_count(); - let mut indices: Vec = (0..row_count).collect(); - - // Sort indices based on row values at col_idx - // When filter is active, we need to look up actual row through filter_indices - let order = self.sort_order; - let filter_indices = &self.filter_indices; - indices.sort_by(|&a, &b| { - // Get actual row index (through filter if active) - let actual_a = if let Some(fi) = filter_indices { - fi[a] - } else { - a - }; - let actual_b = if let Some(fi) = filter_indices { - fi[b] - } else { - b - }; - - let val_a = result.rows[actual_a] - .get(col_idx) - .map(|s| s.as_str()) - .unwrap_or(""); - let val_b = result.rows[actual_b] - .get(col_idx) - .map(|s| s.as_str()) - .unwrap_or(""); - - // Try numeric comparison first - let cmp = match (val_a.parse::(), val_b.parse::()) { - (Ok(num_a), Ok(num_b)) => num_a - .partial_cmp(&num_b) - .unwrap_or(std::cmp::Ordering::Equal), - _ => val_a.cmp(val_b), // Fall back to string comparison - }; - - match order { - SortOrder::Ascending => cmp, - SortOrder::Descending => cmp.reverse(), - SortOrder::None => std::cmp::Ordering::Equal, - } - }); - - self.sort_indices = Some(indices); - } - } - - /// Get row at logical index (respects search, filter, and sort). - /// Returns the actual row from query_result based on search, filter, and sort indices. - fn get_visible_row(&self, logical_index: usize) -> Option<&Vec> { - self.query_result.as_ref().and_then(|result| { - // Step 1: Apply sort (if active), get index into visible rows - let sorted_index = if let Some(ref sort_idx) = self.sort_indices { - *sort_idx.get(logical_index)? - } else { - logical_index - }; - - // Step 2: Get actual row index from filter or search indices - // Priority: Filter (which may operate on search results) > Search > None - let actual_index = match (&self.filter_indices, &self.result_search_indices) { - // Filter is active (may be filtering search results) - (Some(filter), _) => *filter.get(sorted_index)?, - // Only search active - (None, Some(search)) => *search.get(sorted_index)?, - // Neither: direct index - (None, None) => sorted_index, - }; - - result.rows.get(actual_index) - }) - } - - /// Get current sort info for display. - pub fn get_sort_info(&self) -> Option<(String, SortOrder)> { - if let (Some(col_idx), Some(result)) = (self.sort_column_index, &self.query_result) { - if col_idx < result.columns.len() && self.sort_order != SortOrder::None { - return Some((result.columns[col_idx].clone(), self.sort_order)); - } - } - None - } - - // === Filter Modal Methods === - - /// Check if filter modal is active. - pub fn is_filter_mode_active(&self) -> bool { - self.filter_mode_active - } - - /// Open filter column selection modal. - pub fn open_filter_modal(&mut self) { - if let Some(ref result) = self.query_result { - if !result.columns.is_empty() { - self.filter_mode_active = true; - self.filter_modal_step = 0; // Start at column selection - // Start at currently filtered column or first column - self.filter_modal_selection = self.filter_column_index.unwrap_or(0); - } - } - } - - /// Close filter modal without applying. - pub fn close_filter_modal(&mut self) { - self.filter_mode_active = false; - self.filter_modal_step = 0; - // Don't clear filter_text here to preserve it for re-editing - } - - /// Move selection up in filter modal (column selection step). - pub fn filter_modal_up(&mut self) { - if self.filter_modal_step == 0 && self.filter_modal_selection > 0 { - self.filter_modal_selection -= 1; - } - } - - /// Move selection down in filter modal (column selection step). - pub fn filter_modal_down(&mut self) { - if self.filter_modal_step == 0 { - if let Some(ref result) = self.query_result { - if self.filter_modal_selection < result.columns.len().saturating_sub(1) { - self.filter_modal_selection += 1; - } - } - } - } - - /// Move to next step in filter modal (column selection → text input). - pub fn filter_modal_next_step(&mut self) { - if self.filter_modal_step == 0 { - self.filter_modal_step = 1; - // Pre-fill with existing filter text if same column - if self.filter_column_index != Some(self.filter_modal_selection) { - self.filter_text.clear(); - } - } - } - - /// Move to previous step in filter modal (text input → column selection). - pub fn filter_modal_prev_step(&mut self) { - if self.filter_modal_step == 1 { - self.filter_modal_step = 0; - } - } - - /// Handle character input in filter modal (text input step). - pub fn filter_modal_input_char(&mut self, c: char) { - if self.filter_modal_step == 1 { - self.filter_text.push(c); - } - } - - /// Handle backspace in filter modal (text input step). - pub fn filter_modal_delete_char(&mut self) { - if self.filter_modal_step == 1 { - self.filter_text.pop(); - } - } - - /// Apply filter on selected column with current filter text. - pub fn apply_filter(&mut self) { - let selected_col = self.filter_modal_selection; - - if self.filter_text.is_empty() { - // Empty filter text - clear filter - self.clear_filter(); - } else { - // Apply filter - self.filter_column_index = Some(selected_col); - self.update_filter_indices(); - // Re-apply sort on filtered data - if self.sort_order != SortOrder::None { - self.update_sort_indices(); - } - } - - // Close modal - self.filter_mode_active = false; - self.filter_modal_step = 0; - - // Reset to first page and first row after filter - self.result_page = 0; - self.result_table_state.select(Some(0)); - } - - /// Clear filter and restore all rows. - pub fn clear_filter(&mut self) { - self.filter_column_index = None; - self.filter_text.clear(); - self.filter_indices = None; - // Re-apply sort on full data - if self.sort_order != SortOrder::None { - self.update_sort_indices(); - } - } - - /// Update filter indices based on current filter column and text. - /// If search is active, filters within search results. - fn update_filter_indices(&mut self) { - let col_idx = match self.filter_column_index { - Some(idx) => idx, - None => { - self.filter_indices = None; - return; - } - }; - - if self.filter_text.is_empty() { - self.filter_indices = None; - return; - } - - if let Some(ref result) = self.query_result { - if col_idx >= result.columns.len() { - self.filter_indices = None; - return; - } - - // Case-insensitive contains match - let filter_lower = self.filter_text.to_lowercase(); - - // Determine the base set of indices to filter from - let base_indices: Box> = - if let Some(ref search_indices) = self.result_search_indices { - // Filter within search results - Box::new(search_indices.iter().copied()) - } else { - // Filter all rows - Box::new(0..result.rows.len()) - }; - - let indices: Vec = base_indices - .filter(|&i| { - result - .rows - .get(i) - .and_then(|row| row.get(col_idx)) - .map(|cell| cell.to_lowercase().contains(&filter_lower)) - .unwrap_or(false) - }) - .collect(); - - self.filter_indices = Some(indices); - } - } - - /// Get current filter info for display. - /// Returns (column_name, filter_text, visible_row_count) if filter is active. - #[allow(dead_code)] // Designed for status bar display in future - pub fn get_filter_info(&self) -> Option<(String, String, usize)> { - if let (Some(col_idx), Some(result)) = (self.filter_column_index, &self.query_result) { - if col_idx < result.columns.len() && !self.filter_text.is_empty() { - let visible = self.visible_row_count(); - return Some(( - result.columns[col_idx].clone(), - self.filter_text.clone(), - visible, - )); - } - } - None - } - - // === Result Search Methods (all-column search) === - - /// Check if result search mode is active. - pub fn is_result_search_active(&self) -> bool { - self.result_search_active - } - - /// Open result search input. - pub fn open_result_search(&mut self) { - self.result_search_active = true; - // Don't clear previous search text, allow editing - } - - /// Close result search input without applying. - pub fn close_result_search(&mut self) { - self.result_search_active = false; - } - - /// Add a character to result search text (real-time search). - pub fn result_search_input_char(&mut self, c: char) { - self.result_search_text.push(c); - self.update_result_search_indices(); - // If filter is active, re-apply filter on new search results - if self.filter_column_index.is_some() && !self.filter_text.is_empty() { - self.update_filter_indices(); - } - // Re-apply sort on new filtered/searched data - if self.sort_order != SortOrder::None { - self.update_sort_indices(); - } - } - - /// Delete the last character from result search text. - pub fn result_search_delete_char(&mut self) { - self.result_search_text.pop(); - self.update_result_search_indices(); - // If filter is active, re-apply filter on new search results - if self.filter_column_index.is_some() && !self.filter_text.is_empty() { - self.update_filter_indices(); - } - // Re-apply sort on new filtered/searched data - if self.sort_order != SortOrder::None { - self.update_sort_indices(); - } - } - - /// Apply result search and close input. - pub fn apply_result_search(&mut self) { - self.result_search_active = false; - // Reset to first page - self.result_page = 0; - self.result_table_state.select(Some(0)); - } - - /// Clear result search and restore all rows. - pub fn clear_result_search(&mut self) { - self.result_search_text.clear(); - self.result_search_indices = None; - self.result_search_active = false; - // If filter is active, re-apply filter on full data - if self.filter_column_index.is_some() && !self.filter_text.is_empty() { - self.update_filter_indices(); - } - // Re-apply sort - if self.sort_order != SortOrder::None { - self.update_sort_indices(); - } - } - - /// Update result search indices based on current search text. - /// Searches all columns with case-insensitive contains match. - fn update_result_search_indices(&mut self) { - if self.result_search_text.is_empty() { - self.result_search_indices = None; - return; - } - - if let Some(ref result) = self.query_result { - let search_lower = self.result_search_text.to_lowercase(); - let indices: Vec = result - .rows - .iter() - .enumerate() - .filter(|(_, row)| { - // Match if any column contains the search text - row.iter() - .any(|cell| cell.to_lowercase().contains(&search_lower)) - }) - .map(|(i, _)| i) - .collect(); - - self.result_search_indices = Some(indices); - } - } - - /// Get current result search info for display. - /// Returns (search_text, matched_count, total_count) if search is active. - #[allow(dead_code)] // Designed for status bar display - pub fn get_result_search_info(&self) -> Option<(String, usize, usize)> { - if !self.result_search_text.is_empty() { - let total = self - .query_result - .as_ref() - .map(|r| r.rows.len()) - .unwrap_or(0); - let matched = self.visible_row_count(); - return Some((self.result_search_text.clone(), matched, total)); - } - None - } - - /// Scroll result table up by multiple rows (PageUp). - fn scroll_result_page_up(&mut self) { - const SCROLL_AMOUNT: usize = 10; - let current = self.result_table_state.selected().unwrap_or(0); - let new = current.saturating_sub(SCROLL_AMOUNT); - self.result_table_state.select(Some(new)); - } - - /// Scroll result table down by multiple rows (PageDown). - fn scroll_result_page_down(&mut self) { - const SCROLL_AMOUNT: usize = 10; - let current = self.result_table_state.selected().unwrap_or(0); - let page_row_count = self.current_page_row_count(); - let new = (current + SCROLL_AMOUNT).min(page_row_count.saturating_sub(1)); - self.result_table_state.select(Some(new)); - } } impl Component for ContentPanel { @@ -1629,510 +268,7 @@ impl Component for ContentPanel { } fn handle_key(&mut self, key: KeyEvent) -> bool { - // Search mode input handling (takes priority in Questions view) - if self.input_mode == InputMode::Search { - match key.code { - KeyCode::Char(c) => { - self.handle_search_input(c); - true - } - KeyCode::Backspace => { - self.handle_search_backspace(); - true - } - // Enter and Esc are handled by App (to send actions) - _ => false, - } - // Questions view has list navigation - } else if self.view == ContentView::Questions { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.select_previous(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.select_next(); - true - } - KeyCode::Home | KeyCode::Char('g') => { - self.select_first(); - true - } - KeyCode::End | KeyCode::Char('G') => { - self.select_last(); - true - } - KeyCode::Char('/') => { - self.enter_search_mode(); - true - } - _ => false, - } - } else if self.view == ContentView::Collections { - // Collections view has list navigation - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.select_collections_previous(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.select_collections_next(); - true - } - KeyCode::Home | KeyCode::Char('g') => { - self.select_collections_first(); - true - } - KeyCode::End | KeyCode::Char('G') => { - self.select_collections_last(); - true - } - _ => false, - } - } else if self.view == ContentView::Databases { - // Databases view has list navigation - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.select_databases_previous(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.select_databases_next(); - true - } - KeyCode::Home | KeyCode::Char('g') => { - self.select_databases_first(); - true - } - KeyCode::End | KeyCode::Char('G') => { - self.select_databases_last(); - true - } - _ => false, - } - } else if self.is_collection_questions_view() { - // CollectionQuestions view has same navigation as Questions - // Enter/Esc handled by App - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.select_previous(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.select_next(); - true - } - KeyCode::Home | KeyCode::Char('g') => { - self.select_first(); - true - } - KeyCode::End | KeyCode::Char('G') => { - self.select_last(); - true - } - _ => false, - } - } else if self.view == ContentView::QueryResult { - // Filter modal takes priority when active - if self.filter_mode_active { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.filter_modal_up(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.filter_modal_down(); - true - } - KeyCode::Enter => { - if self.filter_modal_step == 0 { - // Column selection → text input - self.filter_modal_next_step(); - } else { - // Text input → apply filter - self.apply_filter(); - } - true - } - KeyCode::Esc => { - if self.filter_modal_step == 1 { - // Text input → column selection - self.filter_modal_prev_step(); - } else { - // Column selection → close modal - self.close_filter_modal(); - } - true - } - KeyCode::Char('f') if self.filter_modal_step == 0 => { - // Close modal if 'f' pressed in column selection - self.close_filter_modal(); - true - } - KeyCode::Backspace => { - self.filter_modal_delete_char(); - true - } - KeyCode::Char(c) if self.filter_modal_step == 1 => { - self.filter_modal_input_char(c); - true - } - _ => false, - } - } else if self.sort_mode_active { - // Sort modal takes priority when active - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.sort_modal_up(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.sort_modal_down(); - true - } - KeyCode::Enter => { - self.apply_sort(); - true - } - KeyCode::Esc | KeyCode::Char('s') => { - self.close_sort_modal(); - true - } - _ => false, - } - } else if self.result_search_active { - // Result search mode takes priority - match key.code { - KeyCode::Char(c) => { - self.result_search_input_char(c); - true - } - KeyCode::Backspace => { - self.result_search_delete_char(); - true - } - KeyCode::Enter => { - self.apply_result_search(); - true - } - KeyCode::Esc => { - self.close_result_search(); - true - } - _ => false, - } - } else { - // QueryResult view has result table navigation + horizontal scroll + pagination - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.select_result_previous(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.select_result_next(); - true - } - // Horizontal scroll with h/l or Left/Right arrows - KeyCode::Left | KeyCode::Char('h') => { - self.scroll_left(); - true - } - KeyCode::Right | KeyCode::Char('l') => { - self.scroll_right(); - true - } - // Pagination: n for next page, p for previous page (matches CLI) - KeyCode::Char('n') => { - self.next_page(); - true - } - KeyCode::Char('p') => { - self.prev_page(); - true - } - // PageUp/PageDown for scrolling within page (matches CLI) - KeyCode::PageUp => { - self.scroll_result_page_up(); - true - } - KeyCode::PageDown => { - self.scroll_result_page_down(); - true - } - // First/Last page with g/G - KeyCode::Home | KeyCode::Char('g') => { - self.first_page(); - true - } - KeyCode::End | KeyCode::Char('G') => { - self.last_page(); - true - } - // Sort: s to open sort modal - KeyCode::Char('s') => { - self.open_sort_modal(); - true - } - // Filter: f to open filter modal - KeyCode::Char('f') => { - self.open_filter_modal(); - true - } - // Clear filter: F (shift+f) to clear filter - KeyCode::Char('F') => { - self.clear_filter(); - // Reset to first page after clearing filter - self.result_page = 0; - self.result_table_state.select(Some(0)); - true - } - // Search: / to open search input - KeyCode::Char('/') => { - self.open_result_search(); - true - } - // Clear search: Shift+S to clear search - KeyCode::Char('S') => { - self.clear_result_search(); - // Reset to first page after clearing search - self.result_page = 0; - self.result_table_state.select(Some(0)); - true - } - // Note: Esc is handled in App for returning to Questions - _ => false, - } - } - } else if self.is_database_schemas_view() { - // DatabaseSchemas view has list navigation - // Enter/Esc handled by App - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.select_schemas_previous(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.select_schemas_next(); - true - } - KeyCode::Home | KeyCode::Char('g') => { - self.select_schemas_first(); - true - } - KeyCode::End | KeyCode::Char('G') => { - self.select_schemas_last(); - true - } - _ => false, - } - } else if self.is_schema_tables_view() { - // SchemaTables view has list navigation - // Enter/Esc handled by App - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.select_tables_previous(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.select_tables_next(); - true - } - KeyCode::Home | KeyCode::Char('g') => { - self.select_tables_first(); - true - } - KeyCode::End | KeyCode::Char('G') => { - self.select_tables_last(); - true - } - _ => false, - } - } else if self.is_table_preview_view() { - // Filter modal takes priority when active - if self.filter_mode_active { - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.filter_modal_up(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.filter_modal_down(); - true - } - KeyCode::Enter => { - if self.filter_modal_step == 0 { - self.filter_modal_next_step(); - } else { - self.apply_filter(); - } - true - } - KeyCode::Esc => { - if self.filter_modal_step == 1 { - self.filter_modal_prev_step(); - } else { - self.close_filter_modal(); - } - true - } - KeyCode::Char('f') if self.filter_modal_step == 0 => { - self.close_filter_modal(); - true - } - KeyCode::Backspace => { - self.filter_modal_delete_char(); - true - } - KeyCode::Char(c) if self.filter_modal_step == 1 => { - self.filter_modal_input_char(c); - true - } - _ => false, - } - } else if self.sort_mode_active { - // Sort modal takes priority when active - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.sort_modal_up(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.sort_modal_down(); - true - } - KeyCode::Enter => { - self.apply_sort(); - true - } - KeyCode::Esc | KeyCode::Char('s') => { - self.close_sort_modal(); - true - } - _ => false, - } - } else if self.result_search_active { - // Result search mode takes priority - match key.code { - KeyCode::Char(c) => { - self.result_search_input_char(c); - true - } - KeyCode::Backspace => { - self.result_search_delete_char(); - true - } - KeyCode::Enter => { - self.apply_result_search(); - true - } - KeyCode::Esc => { - self.close_result_search(); - true - } - _ => false, - } - } else { - // TablePreview view has result table navigation (same as QueryResult) - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.select_result_previous(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.select_result_next(); - true - } - KeyCode::Left | KeyCode::Char('h') => { - self.scroll_left(); - true - } - KeyCode::Right | KeyCode::Char('l') => { - self.scroll_right(); - true - } - KeyCode::Char('n') => { - self.next_page(); - true - } - KeyCode::Char('p') => { - self.prev_page(); - true - } - KeyCode::PageUp => { - self.scroll_result_page_up(); - true - } - KeyCode::PageDown => { - self.scroll_result_page_down(); - true - } - KeyCode::Home | KeyCode::Char('g') => { - self.first_page(); - true - } - KeyCode::End | KeyCode::Char('G') => { - self.last_page(); - true - } - // Sort: s to open sort modal - KeyCode::Char('s') => { - self.open_sort_modal(); - true - } - // Filter: f to open filter modal - KeyCode::Char('f') => { - self.open_filter_modal(); - true - } - // Clear filter: F (shift+f) to clear filter - KeyCode::Char('F') => { - self.clear_filter(); - self.result_page = 0; - self.result_table_state.select(Some(0)); - true - } - // Search: / to open search input - KeyCode::Char('/') => { - self.open_result_search(); - true - } - // Clear search: Shift+S to clear search - KeyCode::Char('S') => { - self.clear_result_search(); - self.result_page = 0; - self.result_table_state.select(Some(0)); - true - } - // Note: Esc is handled in App for returning to SchemaTables - _ => false, - } - } - } else { - // Other views use scroll - match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.scroll.scroll_up(); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.scroll.scroll_down(); - true - } - KeyCode::PageUp => { - for _ in 0..self.scroll.visible { - self.scroll.scroll_up(); - } - true - } - KeyCode::PageDown => { - for _ in 0..self.scroll.visible { - self.scroll.scroll_down(); - } - true - } - _ => false, - } - } + self.handle_key_event(key) } } diff --git a/crates/mbr-tui/src/components/content/navigation.rs b/crates/mbr-tui/src/components/content/navigation.rs new file mode 100644 index 0000000..da1213c --- /dev/null +++ b/crates/mbr-tui/src/components/content/navigation.rs @@ -0,0 +1,300 @@ +//! Navigation stack and view transition methods for ContentPanel. +//! +//! Handles multi-level drill-down navigation (Databases → Schemas → Tables → Preview) +//! and view transitions between different content types. + +use ratatui::widgets::TableState; + +use super::ContentPanel; +use super::types::{ContentView, QueryResultData}; +use crate::service::LoadState; + +impl ContentPanel { + // === Navigation Stack Methods === + + /// Push current view to stack and navigate to new view. + /// Used for drill-down navigation (e.g., Collections → Questions). + pub fn push_view(&mut self, new_view: ContentView) { + self.navigation_stack.push(self.view.clone()); + self.view = new_view; + } + + /// Pop from navigation stack and return to previous view. + /// Returns the view that was popped to, or None if stack was empty. + pub fn pop_view(&mut self) -> Option { + if let Some(previous) = self.navigation_stack.pop() { + self.view = previous.clone(); + Some(previous) + } else { + None + } + } + + /// Get the depth of the navigation stack. + #[allow(dead_code)] // Useful for debugging and future features + pub fn navigation_depth(&self) -> usize { + self.navigation_stack.len() + } + + /// Clear the navigation stack (used when switching tabs). + pub fn clear_navigation_stack(&mut self) { + self.navigation_stack.clear(); + } + + // === Collection Questions View === + + /// Enter collection questions view to show questions from a specific collection. + /// Uses navigation stack for proper back navigation. + pub fn enter_collection_questions(&mut self, collection_id: u32, collection_name: String) { + // Reset questions state for new load + self.questions = LoadState::Idle; + self.table_state = TableState::default(); + // Push new view with embedded context + self.push_view(ContentView::CollectionQuestions { + id: collection_id, + name: collection_name, + }); + } + + /// Exit collection questions view and return to previous view. + /// Uses navigation stack to return to the correct originating view. + pub fn exit_collection_questions(&mut self) { + // Reset questions state + self.questions = LoadState::Idle; + self.table_state = TableState::default(); + // Pop from navigation stack (defaults to Collections if stack is empty) + if self.pop_view().is_none() { + self.view = ContentView::Collections; + } + } + + /// Get the current collection context (id, name) for CollectionQuestions view. + /// Extracts context from the ContentView variant. + #[allow(dead_code)] // Designed for future features + pub fn get_collection_context(&self) -> Option<(u32, String)> { + match &self.view { + ContentView::CollectionQuestions { id, name } => Some((*id, name.clone())), + _ => None, + } + } + + // === Database Drill-down View === + + /// Enter database schemas view to show schemas in a specific database. + /// Uses navigation stack for proper back navigation. + pub fn enter_database_schemas(&mut self, database_id: u32, database_name: String) { + // Reset schemas state for new load + self.schemas = LoadState::Idle; + self.schemas_table_state = TableState::default(); + // Push new view with embedded context + self.push_view(ContentView::DatabaseSchemas { + db_id: database_id, + db_name: database_name, + }); + } + + /// Exit database schemas view and return to previous view. + pub fn exit_database_schemas(&mut self) { + self.schemas = LoadState::Idle; + self.schemas_table_state = TableState::default(); + // Pop from navigation stack (defaults to Databases if stack is empty) + if self.pop_view().is_none() { + self.view = ContentView::Databases; + } + } + + /// Enter schema tables view to show tables in a specific schema. + /// Uses navigation stack for proper back navigation. + pub fn enter_schema_tables(&mut self, database_id: u32, schema_name: String) { + // Reset tables state for new load + self.tables = LoadState::Idle; + self.tables_table_state = TableState::default(); + // Push new view with embedded context + self.push_view(ContentView::SchemaTables { + db_id: database_id, + schema_name, + }); + } + + /// Exit schema tables view and return to previous view. + pub fn exit_schema_tables(&mut self) { + self.tables = LoadState::Idle; + self.tables_table_state = TableState::default(); + // Pop from navigation stack (defaults to DatabaseSchemas if stack is empty) + if self.pop_view().is_none() { + // Fallback without context - should rarely happen + self.view = ContentView::Databases; + } + } + + /// Enter table preview view to show sample data from a table. + /// Uses navigation stack for proper back navigation. + pub fn enter_table_preview(&mut self, database_id: u32, table_id: u32, table_name: String) { + // Reset query result for new load + self.query_result = None; + self.sort_indices = None; + self.filter_indices = None; + self.result_table_state = TableState::default(); + self.result_page = 0; + self.scroll_x = 0; + // Reset sort/filter state + self.reset_sort_filter_state(); + // Push new view with embedded context + self.push_view(ContentView::TablePreview { + db_id: database_id, + table_id, + table_name, + }); + } + + /// Exit table preview view and return to previous view. + pub fn exit_table_preview(&mut self) { + self.query_result = None; + self.sort_indices = None; + self.filter_indices = None; + self.result_table_state = TableState::default(); + self.result_page = 0; + self.scroll_x = 0; + // Reset sort/filter state + self.reset_sort_filter_state(); + // Pop from navigation stack (defaults to SchemaTables if stack is empty) + if self.pop_view().is_none() { + // Fallback without context - should rarely happen + self.view = ContentView::Databases; + } + } + + /// Set table preview data (used when data is loaded after entering preview view). + /// Does not change navigation state since enter_table_preview already handled that. + pub fn set_table_preview_data(&mut self, data: QueryResultData) { + // Clear sort/filter indices for new data + self.sort_indices = None; + self.filter_indices = None; + self.query_result = Some(data); + self.result_table_state = TableState::default(); + self.result_page = 0; + // Reset sort/filter state for new data + self.reset_sort_filter_state(); + // Auto-select first row if available + if self + .query_result + .as_ref() + .is_some_and(|r| !r.rows.is_empty()) + { + self.result_table_state.select(Some(0)); + } + } + + /// Get the current database context (db_id, db_name) for DatabaseSchemas view. + /// Extracts context from the ContentView variant. + pub fn get_database_context(&self) -> Option<(u32, String)> { + match &self.view { + ContentView::DatabaseSchemas { db_id, db_name } => Some((*db_id, db_name.clone())), + ContentView::SchemaTables { db_id, .. } => { + // Also available in SchemaTables since we're drilling down from DatabaseSchemas + // Need to look at navigation stack for the db_name + for view in self.navigation_stack.iter().rev() { + if let ContentView::DatabaseSchemas { db_id: id, db_name } = view { + if *id == *db_id { + return Some((*id, db_name.clone())); + } + } + } + None + } + ContentView::TablePreview { db_id, .. } => { + // Look at navigation stack for database context + for view in self.navigation_stack.iter().rev() { + if let ContentView::DatabaseSchemas { db_id: id, db_name } = view { + if *id == *db_id { + return Some((*id, db_name.clone())); + } + } + } + None + } + _ => None, + } + } + + /// Get the current schema context (db_id, schema_name) for SchemaTables view. + /// Extracts context from the ContentView variant. + pub fn get_schema_context(&self) -> Option<(u32, String)> { + match &self.view { + ContentView::SchemaTables { db_id, schema_name } => Some((*db_id, schema_name.clone())), + ContentView::TablePreview { db_id, .. } => { + // Look at navigation stack for schema context + for view in self.navigation_stack.iter().rev() { + if let ContentView::SchemaTables { + db_id: id, + schema_name, + } = view + { + if *id == *db_id { + return Some((*id, schema_name.clone())); + } + } + } + None + } + _ => None, + } + } + + /// Get the current table context (db_id, table_id, table_name) for TablePreview view. + /// Extracts context from the ContentView variant. + #[allow(dead_code)] // Designed for future features + pub fn get_table_context(&self) -> Option<(u32, u32, String)> { + match &self.view { + ContentView::TablePreview { + db_id, + table_id, + table_name, + } => Some((*db_id, *table_id, table_name.clone())), + _ => None, + } + } + + // === Query Result Navigation === + + /// Set query result data and switch to QueryResult view. + /// Uses navigation stack to enable returning to the originating view. + pub fn set_query_result(&mut self, data: QueryResultData) { + // Clear sort/filter indices for new data + self.sort_indices = None; + self.filter_indices = None; + self.query_result = Some(data); + self.result_table_state = TableState::default(); + self.result_page = 0; // Reset to first page + self.scroll_x = 0; + // Reset sort/filter state for new data + self.reset_sort_filter_state(); + // Auto-select first row if available + if self + .query_result + .as_ref() + .is_some_and(|r| !r.rows.is_empty()) + { + self.result_table_state.select(Some(0)); + } + // Push current view to stack before switching + self.push_view(ContentView::QueryResult); + } + + /// Clear query result and return to previous view. + /// Uses navigation stack to return to the correct originating view. + pub fn back_to_questions(&mut self) { + self.query_result = None; + self.sort_indices = None; + self.filter_indices = None; + self.result_table_state = TableState::default(); + self.result_page = 0; + self.scroll_x = 0; + // Reset sort/filter state + self.reset_sort_filter_state(); + // Pop from navigation stack (defaults to Questions if stack is empty) + if self.pop_view().is_none() { + self.view = ContentView::Questions; + } + } +} diff --git a/crates/mbr-tui/src/components/content/pagination.rs b/crates/mbr-tui/src/components/content/pagination.rs new file mode 100644 index 0000000..cb8c257 --- /dev/null +++ b/crates/mbr-tui/src/components/content/pagination.rs @@ -0,0 +1,164 @@ +//! Pagination and scrolling for query results. +//! +//! Provides page navigation, horizontal scrolling, and row visibility calculations. + +use super::ContentPanel; +use super::types::{ContentView, SortOrder}; + +impl ContentPanel { + /// Get the number of visible rows (after search and filter are applied). + /// + /// Priority: Search first, then Filter. Both are applied if both active. + pub(super) fn visible_row_count(&self) -> usize { + match (&self.result_search_indices, &self.filter_indices) { + // Both search and filter: use filter (which operates on search results) + (Some(_), Some(filter)) => filter.len(), + // Only filter: use filter indices + (None, Some(filter)) => filter.len(), + // Only search: use search indices + (Some(search), None) => search.len(), + // Neither: use all rows + (None, None) => self + .query_result + .as_ref() + .map(|r| r.rows.len()) + .unwrap_or(0), + } + } + + /// Get total number of pages for query result (considers filter). + pub(super) fn total_pages(&self) -> usize { + self.visible_row_count().div_ceil(self.rows_per_page) + } + + /// Go to next page in query result. + pub(super) fn next_page(&mut self) { + let total = self.total_pages(); + if total > 0 && self.result_page < total - 1 { + self.result_page += 1; + self.result_table_state.select(Some(0)); // Reset selection to first row of new page + } + } + + /// Go to previous page in query result. + pub(super) fn prev_page(&mut self) { + if self.result_page > 0 { + self.result_page -= 1; + self.result_table_state.select(Some(0)); // Reset selection to first row of new page + } + } + + /// Go to first page in query result. + pub(super) fn first_page(&mut self) { + self.result_page = 0; + self.result_table_state.select(Some(0)); + } + + /// Go to last page in query result. + pub(super) fn last_page(&mut self) { + let total = self.total_pages(); + if total > 0 { + self.result_page = total - 1; + self.result_table_state.select(Some(0)); + } + } + + /// Scroll left (show previous columns). + pub(super) fn scroll_left(&mut self) { + self.scroll_x = self.scroll_x.saturating_sub(1); + } + + /// Scroll right (show next columns). + pub(super) fn scroll_right(&mut self) { + let total_cols = self.get_total_columns(); + if total_cols > 0 && self.scroll_x < total_cols.saturating_sub(1) { + self.scroll_x += 1; + } + } + + /// Get total number of columns for current view. + fn get_total_columns(&self) -> usize { + match self.view { + ContentView::QueryResult => self + .query_result + .as_ref() + .map(|r| r.columns.len()) + .unwrap_or(0), + ContentView::Questions => 3, // ID, Name, Collection + _ => 0, + } + } + + /// Get the number of rows in the current page. + pub(super) fn current_page_row_count(&self) -> usize { + self.query_result + .as_ref() + .map(|r| { + let total_rows = r.rows.len(); + let page_start = self.result_page * self.rows_per_page; + let page_end = (page_start + self.rows_per_page).min(total_rows); + page_end - page_start + }) + .unwrap_or(0) + } + + /// Get row at logical index (respects search, filter, and sort). + /// Returns the actual row from query_result based on search, filter, and sort indices. + pub(super) fn get_visible_row(&self, logical_index: usize) -> Option<&Vec> { + self.query_result.as_ref().and_then(|result| { + // Step 1: Apply sort (if active), get index into visible rows + let sorted_index = if let Some(ref sort_idx) = self.sort_indices { + *sort_idx.get(logical_index)? + } else { + logical_index + }; + + // Step 2: Get actual row index from filter or search indices + // Priority: Filter (which may operate on search results) > Search > None + let actual_index = match (&self.filter_indices, &self.result_search_indices) { + // Filter is active (may be filtering search results) + (Some(filter), _) => *filter.get(sorted_index)?, + // Only search active + (None, Some(search)) => *search.get(sorted_index)?, + // Neither: direct index + (None, None) => sorted_index, + }; + + result.rows.get(actual_index) + }) + } + + /// Scroll result table up by multiple rows (PageUp). + pub(super) fn scroll_result_page_up(&mut self) { + const SCROLL_AMOUNT: usize = 10; + let current = self.result_table_state.selected().unwrap_or(0); + let new = current.saturating_sub(SCROLL_AMOUNT); + self.result_table_state.select(Some(new)); + } + + /// Scroll result table down by multiple rows (PageDown). + pub(super) fn scroll_result_page_down(&mut self) { + const SCROLL_AMOUNT: usize = 10; + let current = self.result_table_state.selected().unwrap_or(0); + let page_row_count = self.current_page_row_count(); + let new = (current + SCROLL_AMOUNT).min(page_row_count.saturating_sub(1)); + self.result_table_state.select(Some(new)); + } + + /// Reset sort, filter, and search state (helper for view transitions). + pub(super) fn reset_sort_filter_state(&mut self) { + // Sort state + self.sort_order = SortOrder::None; + self.sort_column_index = None; + self.sort_mode_active = false; + // Filter state + self.filter_column_index = None; + self.filter_text.clear(); + self.filter_mode_active = false; + self.filter_modal_step = 0; + // Result search state + self.result_search_active = false; + self.result_search_text.clear(); + self.result_search_indices = None; + } +} diff --git a/crates/mbr-tui/src/components/content/result_search.rs b/crates/mbr-tui/src/components/content/result_search.rs new file mode 100644 index 0000000..81ca886 --- /dev/null +++ b/crates/mbr-tui/src/components/content/result_search.rs @@ -0,0 +1,118 @@ +//! Result search functionality (all-column search). +//! +//! Provides real-time search across all columns in query results, +//! with case-insensitive matching and integration with filter/sort. + +use super::ContentPanel; +use super::types::SortOrder; + +impl ContentPanel { + /// Check if result search mode is active. + pub fn is_result_search_active(&self) -> bool { + self.result_search_active + } + + /// Open result search input. + pub fn open_result_search(&mut self) { + self.result_search_active = true; + // Don't clear previous search text, allow editing + } + + /// Close result search input without applying. + pub fn close_result_search(&mut self) { + self.result_search_active = false; + } + + /// Add a character to result search text (real-time search). + pub fn result_search_input_char(&mut self, c: char) { + self.result_search_text.push(c); + self.update_result_search_indices(); + // If filter is active, re-apply filter on new search results + if self.filter_column_index.is_some() && !self.filter_text.is_empty() { + self.update_filter_indices(); + } + // Re-apply sort on new filtered/searched data + if self.sort_order != SortOrder::None { + self.update_sort_indices(); + } + } + + /// Delete the last character from result search text. + pub fn result_search_delete_char(&mut self) { + self.result_search_text.pop(); + self.update_result_search_indices(); + // If filter is active, re-apply filter on new search results + if self.filter_column_index.is_some() && !self.filter_text.is_empty() { + self.update_filter_indices(); + } + // Re-apply sort on new filtered/searched data + if self.sort_order != SortOrder::None { + self.update_sort_indices(); + } + } + + /// Apply result search and close input. + pub fn apply_result_search(&mut self) { + self.result_search_active = false; + // Reset to first page + self.result_page = 0; + self.result_table_state.select(Some(0)); + } + + /// Clear result search and restore all rows. + pub fn clear_result_search(&mut self) { + self.result_search_text.clear(); + self.result_search_indices = None; + self.result_search_active = false; + // If filter is active, re-apply filter on full data + if self.filter_column_index.is_some() && !self.filter_text.is_empty() { + self.update_filter_indices(); + } + // Re-apply sort + if self.sort_order != SortOrder::None { + self.update_sort_indices(); + } + } + + /// Update result search indices based on current search text. + /// Searches all columns with case-insensitive contains match. + fn update_result_search_indices(&mut self) { + if self.result_search_text.is_empty() { + self.result_search_indices = None; + return; + } + + if let Some(ref result) = self.query_result { + let search_lower = self.result_search_text.to_lowercase(); + let indices: Vec = result + .rows + .iter() + .enumerate() + .filter(|(_, row)| { + // Match if any column contains the search text + row.iter() + .any(|cell| cell.to_lowercase().contains(&search_lower)) + }) + .map(|(i, _)| i) + .collect(); + + self.result_search_indices = Some(indices); + } + } + + /// Get current result search info for display. + /// Returns (search_text, matched_count, total_count) if search is active. + #[allow(dead_code)] // Designed for status bar display + pub fn get_result_search_info(&self) -> Option<(String, usize, usize)> { + if !self.result_search_text.is_empty() { + let total = self + .query_result + .as_ref() + .map(|r| r.rows.len()) + .unwrap_or(0); + let matched = self.visible_row_count(); + return Some((self.result_search_text.clone(), matched, total)); + } + None + } +} diff --git a/crates/mbr-tui/src/components/content/search.rs b/crates/mbr-tui/src/components/content/search.rs new file mode 100644 index 0000000..9cf62b4 --- /dev/null +++ b/crates/mbr-tui/src/components/content/search.rs @@ -0,0 +1,69 @@ +//! Search functionality for Questions view. +//! +//! Handles search mode input, query execution, and search state management. + +use super::ContentPanel; +use super::types::InputMode; + +impl ContentPanel { + /// Get the current input mode. + pub fn input_mode(&self) -> InputMode { + self.input_mode + } + + /// Enter search mode. + pub fn enter_search_mode(&mut self) { + self.input_mode = InputMode::Search; + self.search_query.clear(); + } + + /// Exit search mode without executing search. + pub fn exit_search_mode(&mut self) { + self.input_mode = InputMode::Normal; + self.search_query.clear(); + } + + /// Get the current search query (for debugging/future use). + #[allow(dead_code)] + pub fn get_search_query(&self) -> &str { + &self.search_query + } + + /// Get the active search query (after execution). + pub fn get_active_search(&self) -> Option<&str> { + self.active_search.as_deref() + } + + /// Execute the current search query and return it for API call. + /// Returns Some(query) if there's a query to search, None if empty. + pub fn execute_search(&mut self) -> Option { + self.input_mode = InputMode::Normal; + let query = self.search_query.trim().to_string(); + if query.is_empty() { + self.active_search = None; + None + } else { + self.active_search = Some(query.clone()); + // Reset selection for new results + self.table_state.select(Some(0)); + Some(query) + } + } + + /// Clear the active search and return to showing all questions. + pub fn clear_search(&mut self) { + self.active_search = None; + self.search_query.clear(); + self.table_state.select(Some(0)); + } + + /// Handle character input in search mode. + pub fn handle_search_input(&mut self, c: char) { + self.search_query.push(c); + } + + /// Handle backspace in search mode. + pub fn handle_search_backspace(&mut self) { + self.search_query.pop(); + } +} diff --git a/crates/mbr-tui/src/components/content/selection.rs b/crates/mbr-tui/src/components/content/selection.rs new file mode 100644 index 0000000..3f886a2 --- /dev/null +++ b/crates/mbr-tui/src/components/content/selection.rs @@ -0,0 +1,368 @@ +//! Selection navigation methods for ContentPanel. +//! +//! Provides select_next, select_previous, select_first, select_last methods +//! for each view type (Questions, Collections, Databases, Schemas, Tables, Results). + +use mbr_core::api::models::{CollectionItem, Database, Question, TableInfo}; + +use super::ContentPanel; +use super::types::ContentView; +use crate::service::LoadState; + +impl ContentPanel { + // === Questions view navigation === + + /// Select next question in list. + pub fn select_next(&mut self) { + if let LoadState::Loaded(questions) = &self.questions { + if questions.is_empty() { + return; + } + let current = self.table_state.selected().unwrap_or(0); + let next = (current + 1).min(questions.len() - 1); + self.table_state.select(Some(next)); + } + } + + /// Select previous question in list. + pub fn select_previous(&mut self) { + let current = self.table_state.selected().unwrap_or(0); + let prev = current.saturating_sub(1); + self.table_state.select(Some(prev)); + } + + /// Select first question in list. + pub fn select_first(&mut self) { + self.table_state.select(Some(0)); + } + + /// Select last question in list. + pub fn select_last(&mut self) { + if let LoadState::Loaded(questions) = &self.questions { + if !questions.is_empty() { + self.table_state.select(Some(questions.len() - 1)); + } + } + } + + // === Collections view navigation === + + /// Select next collection in list. + pub(super) fn select_collections_next(&mut self) { + if let LoadState::Loaded(collections) = &self.collections { + if collections.is_empty() { + return; + } + let current = self.collections_table_state.selected().unwrap_or(0); + let next = (current + 1).min(collections.len() - 1); + self.collections_table_state.select(Some(next)); + } + } + + /// Select previous collection in list. + pub(super) fn select_collections_previous(&mut self) { + let current = self.collections_table_state.selected().unwrap_or(0); + let prev = current.saturating_sub(1); + self.collections_table_state.select(Some(prev)); + } + + /// Select first collection in list. + pub(super) fn select_collections_first(&mut self) { + self.collections_table_state.select(Some(0)); + } + + /// Select last collection in list. + pub(super) fn select_collections_last(&mut self) { + if let LoadState::Loaded(collections) = &self.collections { + if !collections.is_empty() { + self.collections_table_state + .select(Some(collections.len() - 1)); + } + } + } + + // === Databases view navigation === + + /// Select next database in list. + pub(super) fn select_databases_next(&mut self) { + if let LoadState::Loaded(databases) = &self.databases { + if databases.is_empty() { + return; + } + let current = self.databases_table_state.selected().unwrap_or(0); + let next = (current + 1).min(databases.len() - 1); + self.databases_table_state.select(Some(next)); + } + } + + /// Select previous database in list. + pub(super) fn select_databases_previous(&mut self) { + let current = self.databases_table_state.selected().unwrap_or(0); + let prev = current.saturating_sub(1); + self.databases_table_state.select(Some(prev)); + } + + /// Select first database in list. + pub(super) fn select_databases_first(&mut self) { + self.databases_table_state.select(Some(0)); + } + + /// Select last database in list. + pub(super) fn select_databases_last(&mut self) { + if let LoadState::Loaded(databases) = &self.databases { + if !databases.is_empty() { + self.databases_table_state.select(Some(databases.len() - 1)); + } + } + } + + // === Schemas view navigation === + + /// Select next schema in list. + pub(super) fn select_schemas_next(&mut self) { + if let LoadState::Loaded(schemas) = &self.schemas { + if schemas.is_empty() { + return; + } + let current = self.schemas_table_state.selected().unwrap_or(0); + let next = (current + 1).min(schemas.len() - 1); + self.schemas_table_state.select(Some(next)); + } + } + + /// Select previous schema in list. + pub(super) fn select_schemas_previous(&mut self) { + let current = self.schemas_table_state.selected().unwrap_or(0); + let prev = current.saturating_sub(1); + self.schemas_table_state.select(Some(prev)); + } + + /// Select first schema in list. + pub(super) fn select_schemas_first(&mut self) { + self.schemas_table_state.select(Some(0)); + } + + /// Select last schema in list. + pub(super) fn select_schemas_last(&mut self) { + if let LoadState::Loaded(schemas) = &self.schemas { + if !schemas.is_empty() { + self.schemas_table_state.select(Some(schemas.len() - 1)); + } + } + } + + // === Tables view navigation === + + /// Select next table in list. + pub(super) fn select_tables_next(&mut self) { + if let LoadState::Loaded(tables) = &self.tables { + if tables.is_empty() { + return; + } + let current = self.tables_table_state.selected().unwrap_or(0); + let next = (current + 1).min(tables.len() - 1); + self.tables_table_state.select(Some(next)); + } + } + + /// Select previous table in list. + pub(super) fn select_tables_previous(&mut self) { + let current = self.tables_table_state.selected().unwrap_or(0); + let prev = current.saturating_sub(1); + self.tables_table_state.select(Some(prev)); + } + + /// Select first table in list. + pub(super) fn select_tables_first(&mut self) { + self.tables_table_state.select(Some(0)); + } + + /// Select last table in list. + pub(super) fn select_tables_last(&mut self) { + if let LoadState::Loaded(tables) = &self.tables { + if !tables.is_empty() { + self.tables_table_state.select(Some(tables.len() - 1)); + } + } + } + + // === Result table navigation === + + /// Navigate result table - select next row. + pub(super) fn select_result_next(&mut self) { + if let Some(ref result) = self.query_result { + if result.rows.is_empty() { + return; + } + let current = self.result_table_state.selected().unwrap_or(0); + let next = (current + 1).min(result.rows.len() - 1); + self.result_table_state.select(Some(next)); + } + } + + /// Navigate result table - select previous row. + pub(super) fn select_result_previous(&mut self) { + let current = self.result_table_state.selected().unwrap_or(0); + let prev = current.saturating_sub(1); + self.result_table_state.select(Some(prev)); + } + + // === Data update methods === + + /// Update questions data from AppData. + /// Automatically selects first item when data is loaded. + pub fn update_questions(&mut self, questions: &LoadState>) { + self.questions = questions.clone(); + + // Auto-select first item when data is loaded + if let LoadState::Loaded(items) = questions { + if !items.is_empty() && self.table_state.selected().is_none() { + self.table_state.select(Some(0)); + } + } + } + + /// Update collections data from AppData. + /// Automatically selects first item when data is loaded. + pub fn update_collections(&mut self, collections: &LoadState>) { + self.collections = collections.clone(); + + // Auto-select first item when data is loaded + if let LoadState::Loaded(items) = collections { + if !items.is_empty() && self.collections_table_state.selected().is_none() { + self.collections_table_state.select(Some(0)); + } + } + } + + /// Update databases data from AppData. + /// Automatically selects first item when data is loaded. + pub fn update_databases(&mut self, databases: &LoadState>) { + self.databases = databases.clone(); + + // Auto-select first item when data is loaded + if let LoadState::Loaded(items) = databases { + if !items.is_empty() && self.databases_table_state.selected().is_none() { + self.databases_table_state.select(Some(0)); + } + } + } + + /// Update schemas data from AppData. + /// Automatically selects first item when data is loaded. + pub fn update_schemas(&mut self, schemas: &LoadState>) { + self.schemas = schemas.clone(); + + // Auto-select first item when data is loaded + if let LoadState::Loaded(items) = schemas { + if !items.is_empty() && self.schemas_table_state.selected().is_none() { + self.schemas_table_state.select(Some(0)); + } + } + } + + /// Update tables data from AppData. + /// Automatically selects first item when data is loaded. + pub fn update_tables(&mut self, tables: &LoadState>) { + self.tables = tables.clone(); + + // Auto-select first item when data is loaded + if let LoadState::Loaded(items) = tables { + if !items.is_empty() && self.tables_table_state.selected().is_none() { + self.tables_table_state.select(Some(0)); + } + } + } + + // === Selected item getters === + + /// Get the currently selected question ID. + /// Works in both Questions and CollectionQuestions views. + pub fn get_selected_question_id(&self) -> Option { + if !self.is_questions_view() { + return None; + } + if let LoadState::Loaded(questions) = &self.questions { + if let Some(selected) = self.table_state.selected() { + return questions.get(selected).map(|q| q.id); + } + } + None + } + + /// Get the currently selected collection info (id, name). + pub fn get_selected_collection_info(&self) -> Option<(u32, String)> { + if self.view != ContentView::Collections { + return None; + } + if let LoadState::Loaded(collections) = &self.collections { + if let Some(selected) = self.collections_table_state.selected() { + return collections + .get(selected) + .and_then(|c| c.id.map(|id| (id, c.name.clone()))); + } + } + None + } + + /// Get the currently selected database info (id, name). + pub fn get_selected_database_info(&self) -> Option<(u32, String)> { + if self.view != ContentView::Databases { + return None; + } + if let LoadState::Loaded(databases) = &self.databases { + if let Some(selected) = self.databases_table_state.selected() { + return databases.get(selected).map(|db| (db.id, db.name.clone())); + } + } + None + } + + /// Get the currently selected schema name. + pub fn get_selected_schema(&self) -> Option { + if !self.is_database_schemas_view() { + return None; + } + if let LoadState::Loaded(schemas) = &self.schemas { + if let Some(selected) = self.schemas_table_state.selected() { + return schemas.get(selected).cloned(); + } + } + None + } + + /// Get the currently selected table info (table_id, table_name). + pub fn get_selected_table_info(&self) -> Option<(u32, String)> { + if !self.is_schema_tables_view() { + return None; + } + if let LoadState::Loaded(tables) = &self.tables { + if let Some(selected) = self.tables_table_state.selected() { + return tables.get(selected).map(|t| (t.id, t.name.clone())); + } + } + None + } + + /// Get the currently selected record in QueryResult or TablePreview view. + /// Returns (columns, values) tuple for the selected row. + /// Respects sort order when sorting is active. + pub fn get_selected_record(&self) -> Option<(Vec, Vec)> { + if !self.is_result_view() { + return None; + } + if let Some(ref result) = self.query_result { + if let Some(selected) = self.result_table_state.selected() { + // Calculate logical index considering pagination + let page_start = self.result_page * self.rows_per_page; + let logical_index = page_start + selected; + + // Get row using sorted index if sorting is active + if let Some(row) = self.get_visible_row(logical_index) { + return Some((result.columns.clone(), row.clone())); + } + } + } + None + } +} diff --git a/crates/mbr-tui/src/components/content/sort.rs b/crates/mbr-tui/src/components/content/sort.rs new file mode 100644 index 0000000..bb0cd5c --- /dev/null +++ b/crates/mbr-tui/src/components/content/sort.rs @@ -0,0 +1,172 @@ +//! Sort functionality for query results. +//! +//! Provides sort modal handling, sort order cycling, and index-based sorting +//! for memory-efficient sorting of large result sets. + +use super::ContentPanel; +use super::types::SortOrder; + +impl ContentPanel { + /// Check if sort modal is active. + pub fn is_sort_mode_active(&self) -> bool { + self.sort_mode_active + } + + /// Open sort column selection modal. + pub fn open_sort_modal(&mut self) { + if let Some(ref result) = self.query_result { + if !result.columns.is_empty() { + self.sort_mode_active = true; + // Start at currently sorted column or first column + self.sort_modal_selection = self.sort_column_index.unwrap_or(0); + } + } + } + + /// Close sort modal without applying. + pub fn close_sort_modal(&mut self) { + self.sort_mode_active = false; + } + + /// Move selection up in sort modal. + pub fn sort_modal_up(&mut self) { + if self.sort_modal_selection > 0 { + self.sort_modal_selection -= 1; + } + } + + /// Move selection down in sort modal. + pub fn sort_modal_down(&mut self) { + if let Some(ref result) = self.query_result { + if self.sort_modal_selection < result.columns.len().saturating_sub(1) { + self.sort_modal_selection += 1; + } + } + } + + /// Apply sort on selected column. + /// If same column is selected, toggles between Ascending -> Descending -> None. + pub fn apply_sort(&mut self) { + let selected_col = self.sort_modal_selection; + + // Toggle sort order + if self.sort_column_index == Some(selected_col) { + // Same column - cycle through orders + self.sort_order = match self.sort_order { + SortOrder::None => SortOrder::Ascending, + SortOrder::Ascending => SortOrder::Descending, + SortOrder::Descending => SortOrder::None, + }; + if self.sort_order == SortOrder::None { + // Restore original order by clearing indices + self.sort_column_index = None; + self.sort_indices = None; + } else { + // Sort indices (not data) + self.update_sort_indices(); + } + } else { + // New column - start with ascending + self.sort_column_index = Some(selected_col); + self.sort_order = SortOrder::Ascending; + // Sort indices (not data) + self.update_sort_indices(); + } + + // Close modal + self.sort_mode_active = false; + + // Reset to first page and first row after sort + self.result_page = 0; + self.result_table_state.select(Some(0)); + } + + /// Clear sort and restore original order. + #[allow(dead_code)] // Designed for future features + pub fn clear_sort(&mut self) { + self.sort_order = SortOrder::None; + self.sort_column_index = None; + self.sort_indices = None; + } + + /// Update sort indices based on current sort column and order. + /// Uses index-based sorting for memory efficiency (no data cloning). + pub(super) fn update_sort_indices(&mut self) { + if self.sort_order == SortOrder::None { + self.sort_indices = None; + return; + } + + let col_idx = match self.sort_column_index { + Some(idx) => idx, + None => { + self.sort_indices = None; + return; + } + }; + + if let Some(ref result) = self.query_result { + if col_idx >= result.columns.len() { + self.sort_indices = None; + return; + } + + // Create index array based on filtered or full row count + let row_count = self.visible_row_count(); + let mut indices: Vec = (0..row_count).collect(); + + // Sort indices based on row values at col_idx + // When filter is active, we need to look up actual row through filter_indices + let order = self.sort_order; + let filter_indices = &self.filter_indices; + indices.sort_by(|&a, &b| { + // Get actual row index (through filter if active) + let actual_a = if let Some(fi) = filter_indices { + fi[a] + } else { + a + }; + let actual_b = if let Some(fi) = filter_indices { + fi[b] + } else { + b + }; + + let val_a = result.rows[actual_a] + .get(col_idx) + .map(|s| s.as_str()) + .unwrap_or(""); + let val_b = result.rows[actual_b] + .get(col_idx) + .map(|s| s.as_str()) + .unwrap_or(""); + + // Try numeric comparison first + let cmp = match (val_a.parse::(), val_b.parse::()) { + (Ok(num_a), Ok(num_b)) => num_a + .partial_cmp(&num_b) + .unwrap_or(std::cmp::Ordering::Equal), + _ => val_a.cmp(val_b), // Fall back to string comparison + }; + + match order { + SortOrder::Ascending => cmp, + SortOrder::Descending => cmp.reverse(), + SortOrder::None => std::cmp::Ordering::Equal, + } + }); + + self.sort_indices = Some(indices); + } + } + + /// Get current sort info for display. + pub fn get_sort_info(&self) -> Option<(String, SortOrder)> { + if let (Some(col_idx), Some(result)) = (self.sort_column_index, &self.query_result) { + if col_idx < result.columns.len() && self.sort_order != SortOrder::None { + return Some((result.columns[col_idx].clone(), self.sort_order)); + } + } + None + } +} diff --git a/crates/mbr-tui/src/components/content/types.rs b/crates/mbr-tui/src/components/content/types.rs new file mode 100644 index 0000000..05e720c --- /dev/null +++ b/crates/mbr-tui/src/components/content/types.rs @@ -0,0 +1,77 @@ +//! Type definitions for the content panel. +//! +//! Contains view types, input modes, sort orders, and query result data structures. + +/// Input mode for text input fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InputMode { + /// Normal navigation mode + #[default] + Normal, + /// Search input mode + Search, +} + +/// Sort order for query results. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SortOrder { + /// No sorting applied + #[default] + None, + /// Ascending order (A-Z, 0-9) + Ascending, + /// Descending order (Z-A, 9-0) + Descending, +} + +/// Content view types with embedded navigation context. +/// +/// Views that represent drill-down navigation carry their context data directly, +/// eliminating the need for separate context fields and ensuring consistency +/// between the navigation stack and the current state. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum ContentView { + #[default] + Welcome, + Questions, + Collections, + Databases, + QueryResult, + /// Questions filtered by a specific collection (id, name) + CollectionQuestions { + id: u32, + name: String, + }, + /// Schemas in a specific database (db_id, db_name) + DatabaseSchemas { + db_id: u32, + db_name: String, + }, + /// Tables in a specific schema (db_id, schema_name) + SchemaTables { + db_id: u32, + schema_name: String, + }, + /// Table data preview (db_id, table_id, table_name) + TablePreview { + db_id: u32, + table_id: u32, + table_name: String, + }, +} + +/// Query result data for display in TUI. +#[derive(Debug, Clone, PartialEq)] +pub struct QueryResultData { + /// Question ID that was executed + pub question_id: u32, + /// Question name for display + pub question_name: String, + /// Column headers + pub columns: Vec, + /// Row data (each cell as string) + pub rows: Vec>, +} + +/// Default rows per page for query result pagination. +pub const DEFAULT_ROWS_PER_PAGE: usize = 100; diff --git a/crates/mbr-tui/src/components/content/views.rs b/crates/mbr-tui/src/components/content/views.rs deleted file mode 100644 index 3476238..0000000 --- a/crates/mbr-tui/src/components/content/views.rs +++ /dev/null @@ -1,1004 +0,0 @@ -//! View rendering functions for ContentPanel. -//! -//! This module contains all the render_* functions that draw different -//! views (Welcome, Questions, Collections, Databases, QueryResult, etc.). - -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Cell, Paragraph, Row, Table, Wrap}, -}; - -use crate::components::state_renderer::{ - LoadStateConfig, render_empty, render_empty_with_hint, render_non_loaded_state, -}; -use crate::components::styles::{ - HIGHLIGHT_SYMBOL, border_style, header_style, result_row_highlight_style, row_highlight_style, -}; -use crate::layout::questions_table::{COLLECTION_WIDTH, ID_WIDTH, NAME_MIN_WIDTH}; -use crate::service::LoadState; - -use super::{ContentPanel, ContentView, InputMode, SortOrder}; - -impl ContentPanel { - /// Render welcome view content. - pub(super) fn render_welcome(&self, _area: Rect, focused: bool) -> Paragraph<'static> { - let border_style = if focused { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::DarkGray) - }; - - let banner = r#" - _ _ _ - _ __ ___ | |__ _ __ | |_ _ _ (_) - | '_ ` _ \ | '_ \| '__|_____ | __| | | || | - | | | | | || |_) | | |_____|| |_| |_| || | - |_| |_| |_||_.__/|_| \__|\__,_||_| -"#; - - let mut lines: Vec = Vec::new(); - lines.push(Line::from("")); - - for banner_line in banner.lines() { - lines.push(Line::from(Span::styled( - banner_line.to_string(), - Style::default().fg(Color::Cyan), - ))); - } - - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " Welcome to mbr-tui!", - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - ))); - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " Select an item from the navigation panel to get started.", - Style::default().fg(Color::DarkGray), - ))); - lines.push(Line::from("")); - lines.push(Line::from(" Quick Keys:")); - lines.push(Line::from(Span::styled( - " Tab - Switch panels", - Style::default().fg(Color::Yellow), - ))); - lines.push(Line::from(Span::styled( - " ↑/↓ j/k - Navigate items", - Style::default().fg(Color::Yellow), - ))); - lines.push(Line::from(Span::styled( - " Enter - Select item", - Style::default().fg(Color::Yellow), - ))); - lines.push(Line::from(Span::styled( - " q - Quit", - Style::default().fg(Color::Yellow), - ))); - - Paragraph::new(lines) - .block( - Block::default() - .title(" Welcome ") - .borders(Borders::ALL) - .border_style(border_style), - ) - .wrap(Wrap { trim: false }) - } - - /// Render questions view with table and search bar. - pub(super) fn render_questions(&mut self, area: Rect, frame: &mut Frame, focused: bool) { - let border_style = if focused { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::DarkGray) - }; - - // Calculate layout: search bar (if visible) + table - let show_search_bar = self.input_mode == InputMode::Search || self.active_search.is_some(); - let (search_area, table_area) = if show_search_bar { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(5)]) - .split(area); - (Some(chunks[0]), chunks[1]) - } else { - (None, area) - }; - - // Render search bar if visible - if let Some(search_rect) = search_area { - let search_text = if self.input_mode == InputMode::Search { - format!("/{}", self.search_query) - } else if let Some(ref query) = self.active_search { - format!("Search: {} (Esc to clear)", query) - } else { - String::new() - }; - - let search_style = if self.input_mode == InputMode::Search { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::DarkGray) - }; - - let search_bar = Paragraph::new(search_text).style(search_style).block( - Block::default() - .borders(Borders::ALL) - .border_style(if self.input_mode == InputMode::Search { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::DarkGray) - }) - .title(" Search (/ to start, Enter to search, Esc to cancel) "), - ); - frame.render_widget(search_bar, search_rect); - } - - // Build title with search indicator - let title = if let Some(ref query) = self.active_search { - match &self.questions { - LoadState::Loaded(questions) => { - format!(" Questions ({}) - Search: \"{}\" ", questions.len(), query) - } - _ => format!(" Questions - Search: \"{}\" ", query), - } - } else { - match &self.questions { - LoadState::Loaded(questions) => format!(" Questions ({}) ", questions.len()), - _ => " Questions ".to_string(), - } - }; - - match &self.questions { - LoadState::Idle => { - let paragraph = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - " Press 'r' to load questions, '/' to search", - Style::default().fg(Color::DarkGray), - )), - ]) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ); - frame.render_widget(paragraph, table_area); - } - LoadState::Loading => { - let paragraph = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - " ⏳ Loading questions...", - Style::default().fg(Color::Yellow), - )), - ]) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ); - frame.render_widget(paragraph, table_area); - } - LoadState::Error(msg) => { - let paragraph = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - format!(" ❌ Error: {}", msg), - Style::default().fg(Color::Red), - )), - Line::from(""), - Line::from(Span::styled( - " Press 'r' to retry", - Style::default().fg(Color::DarkGray), - )), - ]) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ); - frame.render_widget(paragraph, table_area); - } - LoadState::Loaded(questions) => { - if questions.is_empty() { - let empty_msg = if self.active_search.is_some() { - " No questions found matching your search" - } else { - " No questions found" - }; - let paragraph = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - empty_msg, - Style::default().fg(Color::DarkGray), - )), - Line::from(""), - Line::from(Span::styled( - " Press '/' to search or Esc to clear search", - Style::default().fg(Color::DarkGray), - )), - ]) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ); - frame.render_widget(paragraph, table_area); - } else { - // Create table rows - let rows: Vec = questions - .iter() - .map(|q| { - let collection_name = q - .collection - .as_ref() - .map(|c| c.name.as_str()) - .unwrap_or("—"); - - Row::new(vec![ - Cell::from(format!("{}", q.id)), - Cell::from(q.name.clone()), - Cell::from(collection_name.to_string()), - ]) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Length(ID_WIDTH), - Constraint::Min(NAME_MIN_WIDTH), - Constraint::Length(COLLECTION_WIDTH), - ], - ) - .header( - Row::new(vec!["ID", "Name", "Collection"]) - .style( - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - .bottom_margin(1), - ) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(border_style), - ) - .row_highlight_style( - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("► "); - - frame.render_stateful_widget(table, table_area, &mut self.table_state); - } - } - } - } - - /// Render placeholder for unimplemented views. - #[allow(dead_code)] - pub(super) fn render_placeholder(&self, title: &str, focused: bool) -> Paragraph<'static> { - let border_style = if focused { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::DarkGray) - }; - - let lines = vec![ - Line::from(""), - Line::from(Span::styled( - format!(" {} view", title), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - " ⚠ Not implemented yet", - Style::default().fg(Color::Yellow), - )), - Line::from(""), - Line::from(Span::styled( - " This feature is planned for future releases.", - Style::default().fg(Color::DarkGray), - )), - ]; - - Paragraph::new(lines) - .block( - Block::default() - .title(format!(" {} ", title)) - .borders(Borders::ALL) - .border_style(border_style), - ) - .wrap(Wrap { trim: false }) - } - - /// Render collections view with table. - pub(super) fn render_collections(&mut self, area: Rect, frame: &mut Frame, focused: bool) { - let config = LoadStateConfig::new(" Collections ", focused) - .with_idle_message("Press 'r' to load collections") - .with_loading_message("Loading collections..."); - - // Handle non-loaded states with helper - if render_non_loaded_state(frame, area, &self.collections, &config) { - return; - } - - // Handle Loaded state - let collections = match &self.collections { - LoadState::Loaded(c) => c, - _ => return, - }; - - if collections.is_empty() { - render_empty( - frame, - area, - &LoadStateConfig::new(" Collections (0) ", focused), - "No collections found", - ); - return; - } - - // Create table rows - let rows: Vec = collections - .iter() - .map(|c| { - let id_str = - c.id.map(|id| id.to_string()) - .unwrap_or_else(|| "—".to_string()); - let desc = c.description.as_deref().unwrap_or("—"); - let location = c.location.as_deref().unwrap_or("/"); - - Row::new(vec![ - Cell::from(id_str), - Cell::from(c.name.clone()), - Cell::from(location.to_string()), - Cell::from(desc.to_string()), - ]) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Length(ID_WIDTH), - Constraint::Min(NAME_MIN_WIDTH), - Constraint::Length(15), // Location - Constraint::Min(20), // Description - ], - ) - .header( - Row::new(vec!["ID", "Name", "Location", "Description"]) - .style(header_style()) - .bottom_margin(1), - ) - .block( - Block::default() - .title(format!(" Collections ({}) ", collections.len())) - .borders(Borders::ALL) - .border_style(border_style(focused)), - ) - .row_highlight_style(row_highlight_style()) - .highlight_symbol(HIGHLIGHT_SYMBOL); - - frame.render_stateful_widget(table, area, &mut self.collections_table_state); - } - - /// Render databases view with table. - pub(super) fn render_databases(&mut self, area: Rect, frame: &mut Frame, focused: bool) { - let config = LoadStateConfig::new(" Databases ", focused) - .with_idle_message("Press 'r' to load databases") - .with_loading_message("Loading databases..."); - - // Handle non-loaded states with helper - if render_non_loaded_state(frame, area, &self.databases, &config) { - return; - } - - // Handle Loaded state - let databases = match &self.databases { - LoadState::Loaded(d) => d, - _ => return, - }; - - if databases.is_empty() { - render_empty( - frame, - area, - &LoadStateConfig::new(" Databases (0) ", focused), - "No databases found", - ); - return; - } - - // Create table rows - let rows: Vec = databases - .iter() - .map(|db| { - let engine = db.engine.as_deref().unwrap_or("—"); - let desc = db.description.as_deref().unwrap_or("—"); - let sample_marker = if db.is_sample { " (sample)" } else { "" }; - - Row::new(vec![ - Cell::from(format!("{}", db.id)), - Cell::from(format!("{}{}", db.name, sample_marker)), - Cell::from(engine.to_string()), - Cell::from(desc.to_string()), - ]) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Length(ID_WIDTH), - Constraint::Min(NAME_MIN_WIDTH), - Constraint::Length(15), // Engine - Constraint::Min(20), // Description - ], - ) - .header( - Row::new(vec!["ID", "Name", "Engine", "Description"]) - .style(header_style()) - .bottom_margin(1), - ) - .block( - Block::default() - .title(format!(" Databases ({}) ", databases.len())) - .borders(Borders::ALL) - .border_style(border_style(focused)), - ) - .row_highlight_style(row_highlight_style()) - .highlight_symbol(HIGHLIGHT_SYMBOL); - - frame.render_stateful_widget(table, area, &mut self.databases_table_state); - } - - /// Render collection questions view with table. - /// Shows questions filtered by a specific collection. - pub(super) fn render_collection_questions( - &mut self, - area: Rect, - frame: &mut Frame, - focused: bool, - ) { - // Get collection name from ContentView variant - let collection_name = match &self.view { - ContentView::CollectionQuestions { name, .. } => name.as_str(), - _ => "Unknown", - }; - - let title = format!(" {} ", collection_name); - let idle_msg = format!("Loading questions from '{}'...", collection_name); - let loading_msg = format!("Loading questions from '{}'...", collection_name); - let config = LoadStateConfig::new(&title, focused) - .with_idle_message(&idle_msg) - .with_loading_message(&loading_msg); - - // Handle non-loaded states with helper - if render_non_loaded_state(frame, area, &self.questions, &config) { - return; - } - - // Handle Loaded state - let questions = match &self.questions { - LoadState::Loaded(q) => q, - _ => return, - }; - - if questions.is_empty() { - let empty_title = format!(" {} (0) ", collection_name); - let empty_msg = format!("No questions found in '{}'", collection_name); - render_empty_with_hint( - frame, - area, - &LoadStateConfig::new(&empty_title, focused), - &empty_msg, - "Press Esc to go back", - ); - return; - } - - // Create table rows - let rows: Vec = questions - .iter() - .map(|q| { - Row::new(vec![ - Cell::from(format!("{}", q.id)), - Cell::from(q.name.clone()), - Cell::from(q.description.as_deref().unwrap_or("—").to_string()), - ]) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Length(ID_WIDTH), - Constraint::Percentage(35), // Name - Constraint::Percentage(55), // Description (more space) - ], - ) - .header( - Row::new(vec!["ID", "Name", "Description"]) - .style(header_style()) - .bottom_margin(1), - ) - .block( - Block::default() - .title(format!( - " {} ({}) - Press Esc to go back ", - collection_name, - questions.len() - )) - .borders(Borders::ALL) - .border_style(border_style(focused)), - ) - .row_highlight_style(row_highlight_style()) - .highlight_symbol(HIGHLIGHT_SYMBOL); - - frame.render_stateful_widget(table, area, &mut self.table_state); - } - - /// Render database schemas view with table. - /// Shows schemas in a specific database. - pub(super) fn render_database_schemas(&mut self, area: Rect, frame: &mut Frame, focused: bool) { - // Get database name from ContentView variant - let database_name = match &self.view { - ContentView::DatabaseSchemas { db_name, .. } => db_name.as_str(), - _ => "Unknown", - }; - - let title = format!(" {} - Schemas ", database_name); - let idle_msg = format!("Loading schemas from '{}'...", database_name); - let loading_msg = format!("Loading schemas from '{}'...", database_name); - let config = LoadStateConfig::new(&title, focused) - .with_idle_message(&idle_msg) - .with_loading_message(&loading_msg); - - // Handle non-loaded states with helper - if render_non_loaded_state(frame, area, &self.schemas, &config) { - return; - } - - // Handle Loaded state - let schemas = match &self.schemas { - LoadState::Loaded(s) => s, - _ => return, - }; - - if schemas.is_empty() { - let empty_title = format!(" {} - Schemas (0) ", database_name); - let empty_msg = format!("No schemas found in '{}'", database_name); - render_empty_with_hint( - frame, - area, - &LoadStateConfig::new(&empty_title, focused), - &empty_msg, - "Press Esc to go back", - ); - return; - } - - // Create table rows - let rows: Vec = schemas - .iter() - .enumerate() - .map(|(i, schema)| { - Row::new(vec![ - Cell::from(format!("{}", i + 1)), - Cell::from(schema.clone()), - ]) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Length(ID_WIDTH), - Constraint::Min(NAME_MIN_WIDTH), - ], - ) - .header( - Row::new(vec!["#", "Schema Name"]) - .style(header_style()) - .bottom_margin(1), - ) - .block( - Block::default() - .title(format!( - " {} - Schemas ({}) - Press Esc to go back ", - database_name, - schemas.len() - )) - .borders(Borders::ALL) - .border_style(border_style(focused)), - ) - .row_highlight_style(row_highlight_style()) - .highlight_symbol(HIGHLIGHT_SYMBOL); - - frame.render_stateful_widget(table, area, &mut self.schemas_table_state); - } - - /// Render schema tables view with table. - /// Shows tables in a specific schema. - pub(super) fn render_schema_tables(&mut self, area: Rect, frame: &mut Frame, focused: bool) { - // Get schema name from ContentView variant - let schema_name = match &self.view { - ContentView::SchemaTables { schema_name, .. } => schema_name.as_str(), - _ => "Unknown", - }; - - let title = format!(" {} - Tables ", schema_name); - let idle_msg = format!("Loading tables from '{}'...", schema_name); - let loading_msg = format!("Loading tables from '{}'...", schema_name); - let config = LoadStateConfig::new(&title, focused) - .with_idle_message(&idle_msg) - .with_loading_message(&loading_msg); - - // Handle non-loaded states with helper - if render_non_loaded_state(frame, area, &self.tables, &config) { - return; - } - - // Handle Loaded state - let tables = match &self.tables { - LoadState::Loaded(t) => t, - _ => return, - }; - - if tables.is_empty() { - let empty_title = format!(" {} - Tables (0) ", schema_name); - let empty_msg = format!("No tables found in '{}'", schema_name); - render_empty_with_hint( - frame, - area, - &LoadStateConfig::new(&empty_title, focused), - &empty_msg, - "Press Esc to go back", - ); - return; - } - - // Create table rows - let rows: Vec = tables - .iter() - .map(|t| { - let display_name = t.display_name.as_deref().unwrap_or(t.name.as_str()); - let desc = t.description.as_deref().unwrap_or("—"); - Row::new(vec![ - Cell::from(format!("{}", t.id)), - Cell::from(t.name.clone()), - Cell::from(display_name.to_string()), - Cell::from(desc.to_string()), - ]) - }) - .collect(); - - let table = Table::new( - rows, - [ - Constraint::Length(ID_WIDTH), - Constraint::Min(NAME_MIN_WIDTH), - Constraint::Length(20), // Display Name - Constraint::Min(20), // Description - ], - ) - .header( - Row::new(vec!["ID", "Name", "Display Name", "Description"]) - .style(header_style()) - .bottom_margin(1), - ) - .block( - Block::default() - .title(format!( - " {} - Tables ({}) - Press Esc to go back ", - schema_name, - tables.len() - )) - .borders(Borders::ALL) - .border_style(border_style(focused)), - ) - .row_highlight_style(row_highlight_style()) - .highlight_symbol(HIGHLIGHT_SYMBOL); - - frame.render_stateful_widget(table, area, &mut self.tables_table_state); - } - - /// Render table preview view with query result table. - /// Shows sample data from a table (reuses query result rendering). - pub(super) fn render_table_preview(&mut self, area: Rect, frame: &mut Frame, focused: bool) { - // Get table name from ContentView variant (clone to avoid borrow conflict) - let table_name = match &self.view { - ContentView::TablePreview { table_name, .. } => table_name.clone(), - _ => "Unknown".to_string(), - }; - - // Check if we have result data and if it's non-empty - let has_data = self - .query_result - .as_ref() - .map(|r| !r.rows.is_empty()) - .unwrap_or(false); - let is_empty = self - .query_result - .as_ref() - .map(|r| r.rows.is_empty()) - .unwrap_or(false); - - if self.query_result.is_none() { - let paragraph = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - format!(" Loading preview for '{}'...", table_name), - Style::default().fg(Color::Yellow), - )), - ]) - .block( - Block::default() - .title(format!(" {} - Preview ", table_name)) - .borders(Borders::ALL) - .border_style(border_style(focused)), - ); - frame.render_widget(paragraph, area); - } else if is_empty { - let paragraph = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - format!(" Table: {}", table_name), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - " No data in table", - Style::default().fg(Color::DarkGray), - )), - Line::from(""), - Line::from(Span::styled( - " Press Esc to go back", - Style::default().fg(Color::Yellow), - )), - ]) - .block( - Block::default() - .title(format!(" {} - Preview (0 rows) ", table_name)) - .borders(Borders::ALL) - .border_style(border_style(focused)), - ); - frame.render_widget(paragraph, area); - } else if has_data { - self.render_result_table(frame, area, focused, &table_name); - } - } - - /// Render query result view with table. - pub(super) fn render_query_result(&mut self, area: Rect, frame: &mut Frame, focused: bool) { - // Extract info before mutable borrow - let (has_result, is_empty, question_name) = match &self.query_result { - None => (false, false, String::new()), - Some(result) => (true, result.rows.is_empty(), result.question_name.clone()), - }; - - if !has_result { - let paragraph = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - " No query result available", - Style::default().fg(Color::DarkGray), - )), - ]) - .block( - Block::default() - .title(" Query Result ") - .borders(Borders::ALL) - .border_style(border_style(focused)), - ); - frame.render_widget(paragraph, area); - } else if is_empty { - let paragraph = Paragraph::new(vec![ - Line::from(""), - Line::from(Span::styled( - format!(" Query: {}", question_name), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), - )), - Line::from(""), - Line::from(Span::styled( - " No data returned", - Style::default().fg(Color::DarkGray), - )), - Line::from(""), - Line::from(Span::styled( - " Press Esc to go back", - Style::default().fg(Color::Yellow), - )), - ]) - .block( - Block::default() - .title(format!(" Query Result: {} (0 rows) ", question_name)) - .borders(Borders::ALL) - .border_style(border_style(focused)), - ); - frame.render_widget(paragraph, area); - } else { - self.render_result_table(frame, area, focused, &question_name); - } - } - - /// Common result table rendering logic for both TablePreview and QueryResult. - /// Accesses self.query_result internally to avoid borrow conflicts. - fn render_result_table( - &mut self, - frame: &mut Frame, - area: Rect, - focused: bool, - title_prefix: &str, - ) { - // Get result reference - caller guarantees query_result is Some - let result = match &self.query_result { - Some(r) => r, - None => return, - }; - - // Pagination: calculate row range for current page - let total_rows = result.rows.len(); - let total_pages = self.total_pages(); - let page_start = self.result_page * self.rows_per_page; - let page_end = (page_start + self.rows_per_page).min(total_rows); - - // Calculate visible columns based on scroll_x - let total_cols = result.columns.len(); - let scroll_x = self.scroll_x.min(total_cols.saturating_sub(1)); - - // Calculate how many columns can fit (estimate based on min width) - let available_width = area.width.saturating_sub(4) as usize; - let min_col_width = 15usize; - let visible_cols = (available_width / min_col_width).max(1).min(total_cols); - let end_col = (scroll_x + visible_cols).min(total_cols); - - // Slice columns based on scroll position - let visible_columns: Vec = result.columns[scroll_x..end_col].to_vec(); - let visible_col_count = visible_columns.len(); - - // Create dynamic column widths - let constraints: Vec = if visible_col_count <= 3 { - visible_columns - .iter() - .map(|_| Constraint::Ratio(1, visible_col_count as u32)) - .collect() - } else { - visible_columns - .iter() - .map(|_| Constraint::Min(15)) - .collect() - }; - - // Create table rows with sliced cells (only current page) - let rows: Vec = (page_start..page_end) - .filter_map(|logical_idx| self.get_visible_row(logical_idx)) - .map(|row| { - let cells: Vec = row[scroll_x..end_col.min(row.len())] - .iter() - .map(|cell| Cell::from(cell.clone())) - .collect(); - Row::new(cells) - }) - .collect(); - - // Create header row with sort indicators - let header_cells: Vec = visible_columns - .iter() - .enumerate() - .map(|(visible_idx, col)| { - let actual_col_idx = scroll_x + visible_idx; - let is_sorted = self.sort_column_index == Some(actual_col_idx); - - let header_text = if is_sorted { - let indicator = match self.sort_order { - SortOrder::Ascending => " ↑", - SortOrder::Descending => " ↓", - SortOrder::None => "", - }; - format!("{}{}", col, indicator) - } else { - col.clone() - }; - - Cell::from(header_text) - }) - .collect(); - - // Build column indicator - let col_indicator = if total_cols > visible_cols { - let left_arrow = if scroll_x > 0 { "← " } else { " " }; - let right_arrow = if end_col < total_cols { " →" } else { " " }; - format!( - " {}Col {}-{}/{}{}", - left_arrow, - scroll_x + 1, - end_col, - total_cols, - right_arrow - ) - } else { - String::new() - }; - - // Build page indicator - let page_indicator = if total_pages > 1 { - format!( - " Page {}/{} (rows {}-{} of {})", - self.result_page + 1, - total_pages, - page_start + 1, - page_end, - total_rows - ) - } else { - format!(" {} rows", total_rows) - }; - - // Build sort indicator for title - let sort_indicator = if let Some((col_name, order)) = self.get_sort_info() { - let arrow = match order { - SortOrder::Ascending => "↑", - SortOrder::Descending => "↓", - SortOrder::None => "", - }; - format!(" [Sort: {} {}]", col_name, arrow) - } else { - String::new() - }; - - let table = Table::new(rows, constraints) - .header( - Row::new(header_cells) - .style(header_style()) - .bottom_margin(1), - ) - .block( - Block::default() - .title(format!( - " {}{}{}{}", - title_prefix, page_indicator, col_indicator, sort_indicator - )) - .borders(Borders::ALL) - .border_style(border_style(focused)), - ) - .row_highlight_style(result_row_highlight_style()) - .highlight_symbol(HIGHLIGHT_SYMBOL); - - frame.render_stateful_widget(table, area, &mut self.result_table_state); - - // Render overlays - if self.sort_mode_active { - self.render_sort_modal(frame, area); - } - if self.filter_mode_active { - self.render_filter_modal(frame, area); - } - if self.result_search_active { - self.render_result_search_bar(frame, area); - } - } -} diff --git a/crates/mbr-tui/src/components/content/views/drill_down.rs b/crates/mbr-tui/src/components/content/views/drill_down.rs new file mode 100644 index 0000000..65f456f --- /dev/null +++ b/crates/mbr-tui/src/components/content/views/drill_down.rs @@ -0,0 +1,283 @@ +//! Drill-down view rendering for nested data exploration. +//! +//! - Collection → Questions +//! - Database → Schemas → Tables + +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + widgets::{Block, Borders, Cell, Row, Table}, +}; + +use crate::components::content::{ContentPanel, ContentView}; +use crate::components::state_renderer::{ + LoadStateConfig, render_empty_with_hint, render_non_loaded_state, +}; +use crate::components::styles::{ + HIGHLIGHT_SYMBOL, border_style, header_style, row_highlight_style, +}; +use crate::layout::questions_table::{ID_WIDTH, NAME_MIN_WIDTH}; +use crate::service::LoadState; + +impl ContentPanel { + /// Render collection questions view with table. + /// Shows questions filtered by a specific collection. + pub(in crate::components::content) fn render_collection_questions( + &mut self, + area: Rect, + frame: &mut Frame, + focused: bool, + ) { + // Get collection name from ContentView variant + let collection_name = match &self.view { + ContentView::CollectionQuestions { name, .. } => name.as_str(), + _ => "Unknown", + }; + + let title = format!(" {} ", collection_name); + let idle_msg = format!("Loading questions from '{}'...", collection_name); + let loading_msg = format!("Loading questions from '{}'...", collection_name); + let config = LoadStateConfig::new(&title, focused) + .with_idle_message(&idle_msg) + .with_loading_message(&loading_msg); + + // Handle non-loaded states with helper + if render_non_loaded_state(frame, area, &self.questions, &config) { + return; + } + + // Handle Loaded state + let questions = match &self.questions { + LoadState::Loaded(q) => q, + _ => return, + }; + + if questions.is_empty() { + let empty_title = format!(" {} (0) ", collection_name); + let empty_msg = format!("No questions found in '{}'", collection_name); + render_empty_with_hint( + frame, + area, + &LoadStateConfig::new(&empty_title, focused), + &empty_msg, + "Press Esc to go back", + ); + return; + } + + // Create table rows + let rows: Vec = questions + .iter() + .map(|q| { + Row::new(vec![ + Cell::from(format!("{}", q.id)), + Cell::from(q.name.clone()), + Cell::from(q.description.as_deref().unwrap_or("—").to_string()), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(ID_WIDTH), + Constraint::Percentage(35), // Name + Constraint::Percentage(55), // Description (more space) + ], + ) + .header( + Row::new(vec!["ID", "Name", "Description"]) + .style(header_style()) + .bottom_margin(1), + ) + .block( + Block::default() + .title(format!( + " {} ({}) - Press Esc to go back ", + collection_name, + questions.len() + )) + .borders(Borders::ALL) + .border_style(border_style(focused)), + ) + .row_highlight_style(row_highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL); + + frame.render_stateful_widget(table, area, &mut self.table_state); + } + + /// Render database schemas view with table. + /// Shows schemas in a specific database. + pub(in crate::components::content) fn render_database_schemas( + &mut self, + area: Rect, + frame: &mut Frame, + focused: bool, + ) { + // Get database name from ContentView variant + let database_name = match &self.view { + ContentView::DatabaseSchemas { db_name, .. } => db_name.as_str(), + _ => "Unknown", + }; + + let title = format!(" {} - Schemas ", database_name); + let idle_msg = format!("Loading schemas from '{}'...", database_name); + let loading_msg = format!("Loading schemas from '{}'...", database_name); + let config = LoadStateConfig::new(&title, focused) + .with_idle_message(&idle_msg) + .with_loading_message(&loading_msg); + + // Handle non-loaded states with helper + if render_non_loaded_state(frame, area, &self.schemas, &config) { + return; + } + + // Handle Loaded state + let schemas = match &self.schemas { + LoadState::Loaded(s) => s, + _ => return, + }; + + if schemas.is_empty() { + let empty_title = format!(" {} - Schemas (0) ", database_name); + let empty_msg = format!("No schemas found in '{}'", database_name); + render_empty_with_hint( + frame, + area, + &LoadStateConfig::new(&empty_title, focused), + &empty_msg, + "Press Esc to go back", + ); + return; + } + + // Create table rows + let rows: Vec = schemas + .iter() + .enumerate() + .map(|(i, schema)| { + Row::new(vec![ + Cell::from(format!("{}", i + 1)), + Cell::from(schema.clone()), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(ID_WIDTH), + Constraint::Min(NAME_MIN_WIDTH), + ], + ) + .header( + Row::new(vec!["#", "Schema Name"]) + .style(header_style()) + .bottom_margin(1), + ) + .block( + Block::default() + .title(format!( + " {} - Schemas ({}) - Press Esc to go back ", + database_name, + schemas.len() + )) + .borders(Borders::ALL) + .border_style(border_style(focused)), + ) + .row_highlight_style(row_highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL); + + frame.render_stateful_widget(table, area, &mut self.schemas_table_state); + } + + /// Render schema tables view with table. + /// Shows tables in a specific schema. + pub(in crate::components::content) fn render_schema_tables( + &mut self, + area: Rect, + frame: &mut Frame, + focused: bool, + ) { + // Get schema name from ContentView variant + let schema_name = match &self.view { + ContentView::SchemaTables { schema_name, .. } => schema_name.as_str(), + _ => "Unknown", + }; + + let title = format!(" {} - Tables ", schema_name); + let idle_msg = format!("Loading tables from '{}'...", schema_name); + let loading_msg = format!("Loading tables from '{}'...", schema_name); + let config = LoadStateConfig::new(&title, focused) + .with_idle_message(&idle_msg) + .with_loading_message(&loading_msg); + + // Handle non-loaded states with helper + if render_non_loaded_state(frame, area, &self.tables, &config) { + return; + } + + // Handle Loaded state + let tables = match &self.tables { + LoadState::Loaded(t) => t, + _ => return, + }; + + if tables.is_empty() { + let empty_title = format!(" {} - Tables (0) ", schema_name); + let empty_msg = format!("No tables found in '{}'", schema_name); + render_empty_with_hint( + frame, + area, + &LoadStateConfig::new(&empty_title, focused), + &empty_msg, + "Press Esc to go back", + ); + return; + } + + // Create table rows + let rows: Vec = tables + .iter() + .map(|t| { + let display_name = t.display_name.as_deref().unwrap_or(t.name.as_str()); + let desc = t.description.as_deref().unwrap_or("—"); + Row::new(vec![ + Cell::from(format!("{}", t.id)), + Cell::from(t.name.clone()), + Cell::from(display_name.to_string()), + Cell::from(desc.to_string()), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(ID_WIDTH), + Constraint::Min(NAME_MIN_WIDTH), + Constraint::Length(20), // Display Name + Constraint::Min(20), // Description + ], + ) + .header( + Row::new(vec!["ID", "Name", "Display Name", "Description"]) + .style(header_style()) + .bottom_margin(1), + ) + .block( + Block::default() + .title(format!( + " {} - Tables ({}) - Press Esc to go back ", + schema_name, + tables.len() + )) + .borders(Borders::ALL) + .border_style(border_style(focused)), + ) + .row_highlight_style(row_highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL); + + frame.render_stateful_widget(table, area, &mut self.tables_table_state); + } +} diff --git a/crates/mbr-tui/src/components/content/views/lists.rs b/crates/mbr-tui/src/components/content/views/lists.rs new file mode 100644 index 0000000..3e9504f --- /dev/null +++ b/crates/mbr-tui/src/components/content/views/lists.rs @@ -0,0 +1,376 @@ +//! List view rendering for Questions, Collections, and Databases. + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, +}; + +use crate::components::content::{ContentPanel, InputMode}; +use crate::components::state_renderer::{LoadStateConfig, render_empty, render_non_loaded_state}; +use crate::components::styles::{ + HIGHLIGHT_SYMBOL, border_style, header_style, row_highlight_style, +}; +use crate::layout::questions_table::{COLLECTION_WIDTH, ID_WIDTH, NAME_MIN_WIDTH}; +use crate::service::LoadState; + +impl ContentPanel { + /// Render questions view with table and search bar. + pub(in crate::components::content) fn render_questions( + &mut self, + area: Rect, + frame: &mut Frame, + focused: bool, + ) { + let border_color = if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + + // Calculate layout: search bar (if visible) + table + let show_search_bar = self.input_mode == InputMode::Search || self.active_search.is_some(); + let (search_area, table_area) = if show_search_bar { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(5)]) + .split(area); + (Some(chunks[0]), chunks[1]) + } else { + (None, area) + }; + + // Render search bar if visible + if let Some(search_rect) = search_area { + let search_text = if self.input_mode == InputMode::Search { + format!("/{}", self.search_query) + } else if let Some(ref query) = self.active_search { + format!("Search: {} (Esc to clear)", query) + } else { + String::new() + }; + + let search_style = if self.input_mode == InputMode::Search { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + + let search_bar = Paragraph::new(search_text).style(search_style).block( + Block::default() + .borders(Borders::ALL) + .border_style(if self.input_mode == InputMode::Search { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }) + .title(" Search (/ to start, Enter to search, Esc to cancel) "), + ); + frame.render_widget(search_bar, search_rect); + } + + // Build title with search indicator + let title = if let Some(ref query) = self.active_search { + match &self.questions { + LoadState::Loaded(questions) => { + format!(" Questions ({}) - Search: \"{}\" ", questions.len(), query) + } + _ => format!(" Questions - Search: \"{}\" ", query), + } + } else { + match &self.questions { + LoadState::Loaded(questions) => format!(" Questions ({}) ", questions.len()), + _ => " Questions ".to_string(), + } + }; + + match &self.questions { + LoadState::Idle => { + let paragraph = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + " Press 'r' to load questions, '/' to search", + Style::default().fg(Color::DarkGray), + )), + ]) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_color), + ); + frame.render_widget(paragraph, table_area); + } + LoadState::Loading => { + let paragraph = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + " ⏳ Loading questions...", + Style::default().fg(Color::Yellow), + )), + ]) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_color), + ); + frame.render_widget(paragraph, table_area); + } + LoadState::Error(msg) => { + let paragraph = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + format!(" ❌ Error: {}", msg), + Style::default().fg(Color::Red), + )), + Line::from(""), + Line::from(Span::styled( + " Press 'r' to retry", + Style::default().fg(Color::DarkGray), + )), + ]) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_color), + ); + frame.render_widget(paragraph, table_area); + } + LoadState::Loaded(questions) => { + if questions.is_empty() { + let empty_msg = if self.active_search.is_some() { + " No questions found matching your search" + } else { + " No questions found" + }; + let paragraph = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + empty_msg, + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(Span::styled( + " Press '/' to search or Esc to clear search", + Style::default().fg(Color::DarkGray), + )), + ]) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_color), + ); + frame.render_widget(paragraph, table_area); + } else { + // Create table rows + let rows: Vec = questions + .iter() + .map(|q| { + let collection_name = q + .collection + .as_ref() + .map(|c| c.name.as_str()) + .unwrap_or("—"); + + Row::new(vec![ + Cell::from(format!("{}", q.id)), + Cell::from(q.name.clone()), + Cell::from(collection_name.to_string()), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(ID_WIDTH), + Constraint::Min(NAME_MIN_WIDTH), + Constraint::Length(COLLECTION_WIDTH), + ], + ) + .header( + Row::new(vec!["ID", "Name", "Collection"]) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .bottom_margin(1), + ) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_color), + ) + .row_highlight_style( + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("► "); + + frame.render_stateful_widget(table, table_area, &mut self.table_state); + } + } + } + } + + /// Render collections view with table. + pub(in crate::components::content) fn render_collections( + &mut self, + area: Rect, + frame: &mut Frame, + focused: bool, + ) { + let config = LoadStateConfig::new(" Collections ", focused) + .with_idle_message("Press 'r' to load collections") + .with_loading_message("Loading collections..."); + + // Handle non-loaded states with helper + if render_non_loaded_state(frame, area, &self.collections, &config) { + return; + } + + // Handle Loaded state + let collections = match &self.collections { + LoadState::Loaded(c) => c, + _ => return, + }; + + if collections.is_empty() { + render_empty( + frame, + area, + &LoadStateConfig::new(" Collections (0) ", focused), + "No collections found", + ); + return; + } + + // Create table rows + let rows: Vec = collections + .iter() + .map(|c| { + let id_str = + c.id.map(|id| id.to_string()) + .unwrap_or_else(|| "—".to_string()); + let desc = c.description.as_deref().unwrap_or("—"); + let location = c.location.as_deref().unwrap_or("/"); + + Row::new(vec![ + Cell::from(id_str), + Cell::from(c.name.clone()), + Cell::from(location.to_string()), + Cell::from(desc.to_string()), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(ID_WIDTH), + Constraint::Min(NAME_MIN_WIDTH), + Constraint::Length(15), // Location + Constraint::Min(20), // Description + ], + ) + .header( + Row::new(vec!["ID", "Name", "Location", "Description"]) + .style(header_style()) + .bottom_margin(1), + ) + .block( + Block::default() + .title(format!(" Collections ({}) ", collections.len())) + .borders(Borders::ALL) + .border_style(border_style(focused)), + ) + .row_highlight_style(row_highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL); + + frame.render_stateful_widget(table, area, &mut self.collections_table_state); + } + + /// Render databases view with table. + pub(in crate::components::content) fn render_databases( + &mut self, + area: Rect, + frame: &mut Frame, + focused: bool, + ) { + let config = LoadStateConfig::new(" Databases ", focused) + .with_idle_message("Press 'r' to load databases") + .with_loading_message("Loading databases..."); + + // Handle non-loaded states with helper + if render_non_loaded_state(frame, area, &self.databases, &config) { + return; + } + + // Handle Loaded state + let databases = match &self.databases { + LoadState::Loaded(d) => d, + _ => return, + }; + + if databases.is_empty() { + render_empty( + frame, + area, + &LoadStateConfig::new(" Databases (0) ", focused), + "No databases found", + ); + return; + } + + // Create table rows + let rows: Vec = databases + .iter() + .map(|db| { + let engine = db.engine.as_deref().unwrap_or("—"); + let desc = db.description.as_deref().unwrap_or("—"); + let sample_marker = if db.is_sample { " (sample)" } else { "" }; + + Row::new(vec![ + Cell::from(format!("{}", db.id)), + Cell::from(format!("{}{}", db.name, sample_marker)), + Cell::from(engine.to_string()), + Cell::from(desc.to_string()), + ]) + }) + .collect(); + + let table = Table::new( + rows, + [ + Constraint::Length(ID_WIDTH), + Constraint::Min(NAME_MIN_WIDTH), + Constraint::Length(15), // Engine + Constraint::Min(20), // Description + ], + ) + .header( + Row::new(vec!["ID", "Name", "Engine", "Description"]) + .style(header_style()) + .bottom_margin(1), + ) + .block( + Block::default() + .title(format!(" Databases ({}) ", databases.len())) + .borders(Borders::ALL) + .border_style(border_style(focused)), + ) + .row_highlight_style(row_highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL); + + frame.render_stateful_widget(table, area, &mut self.databases_table_state); + } +} diff --git a/crates/mbr-tui/src/components/content/views/mod.rs b/crates/mbr-tui/src/components/content/views/mod.rs new file mode 100644 index 0000000..a537ffa --- /dev/null +++ b/crates/mbr-tui/src/components/content/views/mod.rs @@ -0,0 +1,15 @@ +//! View rendering functions for ContentPanel. +//! +//! This module contains all the render_* functions that draw different +//! views (Welcome, Questions, Collections, Databases, QueryResult, etc.). +//! +//! ## Module Structure +//! - `welcome.rs`: Welcome screen and placeholder rendering +//! - `lists.rs`: Questions, Collections, Databases list views +//! - `drill_down.rs`: Collection questions, schemas, tables drill-down views +//! - `results.rs`: Query result and table preview rendering + +mod drill_down; +mod lists; +mod results; +mod welcome; diff --git a/crates/mbr-tui/src/components/content/views/results.rs b/crates/mbr-tui/src/components/content/views/results.rs new file mode 100644 index 0000000..6a00dce --- /dev/null +++ b/crates/mbr-tui/src/components/content/views/results.rs @@ -0,0 +1,306 @@ +//! Query result and table preview rendering. + +use ratatui::{ + Frame, + layout::{Constraint, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Cell, Paragraph, Row, Table}, +}; + +use crate::components::content::{ContentPanel, ContentView, SortOrder}; +use crate::components::styles::{ + HIGHLIGHT_SYMBOL, border_style, header_style, result_row_highlight_style, +}; + +impl ContentPanel { + /// Render table preview view with query result table. + /// Shows sample data from a table (reuses query result rendering). + pub(in crate::components::content) fn render_table_preview( + &mut self, + area: Rect, + frame: &mut Frame, + focused: bool, + ) { + // Get table name from ContentView variant (clone to avoid borrow conflict) + let table_name = match &self.view { + ContentView::TablePreview { table_name, .. } => table_name.clone(), + _ => "Unknown".to_string(), + }; + + // Check if we have result data and if it's non-empty + let has_data = self + .query_result + .as_ref() + .map(|r| !r.rows.is_empty()) + .unwrap_or(false); + let is_empty = self + .query_result + .as_ref() + .map(|r| r.rows.is_empty()) + .unwrap_or(false); + + if self.query_result.is_none() { + let paragraph = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + format!(" Loading preview for '{}'...", table_name), + Style::default().fg(Color::Yellow), + )), + ]) + .block( + Block::default() + .title(format!(" {} - Preview ", table_name)) + .borders(Borders::ALL) + .border_style(border_style(focused)), + ); + frame.render_widget(paragraph, area); + } else if is_empty { + let paragraph = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + format!(" Table: {}", table_name), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + " No data in table", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(Span::styled( + " Press Esc to go back", + Style::default().fg(Color::Yellow), + )), + ]) + .block( + Block::default() + .title(format!(" {} - Preview (0 rows) ", table_name)) + .borders(Borders::ALL) + .border_style(border_style(focused)), + ); + frame.render_widget(paragraph, area); + } else if has_data { + self.render_result_table(frame, area, focused, &table_name); + } + } + + /// Render query result view with table. + pub(in crate::components::content) fn render_query_result( + &mut self, + area: Rect, + frame: &mut Frame, + focused: bool, + ) { + // Extract info before mutable borrow + let (has_result, is_empty, question_name) = match &self.query_result { + None => (false, false, String::new()), + Some(result) => (true, result.rows.is_empty(), result.question_name.clone()), + }; + + if !has_result { + let paragraph = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + " No query result available", + Style::default().fg(Color::DarkGray), + )), + ]) + .block( + Block::default() + .title(" Query Result ") + .borders(Borders::ALL) + .border_style(border_style(focused)), + ); + frame.render_widget(paragraph, area); + } else if is_empty { + let paragraph = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + format!(" Query: {}", question_name), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + " No data returned", + Style::default().fg(Color::DarkGray), + )), + Line::from(""), + Line::from(Span::styled( + " Press Esc to go back", + Style::default().fg(Color::Yellow), + )), + ]) + .block( + Block::default() + .title(format!(" Query Result: {} (0 rows) ", question_name)) + .borders(Borders::ALL) + .border_style(border_style(focused)), + ); + frame.render_widget(paragraph, area); + } else { + self.render_result_table(frame, area, focused, &question_name); + } + } + + /// Common result table rendering logic for both TablePreview and QueryResult. + /// Accesses self.query_result internally to avoid borrow conflicts. + pub(in crate::components::content) fn render_result_table( + &mut self, + frame: &mut Frame, + area: Rect, + focused: bool, + title_prefix: &str, + ) { + // Get result reference - caller guarantees query_result is Some + let result = match &self.query_result { + Some(r) => r, + None => return, + }; + + // Pagination: calculate row range for current page + let total_rows = result.rows.len(); + let total_pages = self.total_pages(); + let page_start = self.result_page * self.rows_per_page; + let page_end = (page_start + self.rows_per_page).min(total_rows); + + // Calculate visible columns based on scroll_x + let total_cols = result.columns.len(); + let scroll_x = self.scroll_x.min(total_cols.saturating_sub(1)); + + // Calculate how many columns can fit (estimate based on min width) + let available_width = area.width.saturating_sub(4) as usize; + let min_col_width = 15usize; + let visible_cols = (available_width / min_col_width).max(1).min(total_cols); + let end_col = (scroll_x + visible_cols).min(total_cols); + + // Slice columns based on scroll position + let visible_columns: Vec = result.columns[scroll_x..end_col].to_vec(); + let visible_col_count = visible_columns.len(); + + // Create dynamic column widths + let constraints: Vec = if visible_col_count <= 3 { + visible_columns + .iter() + .map(|_| Constraint::Ratio(1, visible_col_count as u32)) + .collect() + } else { + visible_columns + .iter() + .map(|_| Constraint::Min(15)) + .collect() + }; + + // Create table rows with sliced cells (only current page) + let rows: Vec = (page_start..page_end) + .filter_map(|logical_idx| self.get_visible_row(logical_idx)) + .map(|row| { + let cells: Vec = row[scroll_x..end_col.min(row.len())] + .iter() + .map(|cell| Cell::from(cell.clone())) + .collect(); + Row::new(cells) + }) + .collect(); + + // Create header row with sort indicators + let header_cells: Vec = visible_columns + .iter() + .enumerate() + .map(|(visible_idx, col)| { + let actual_col_idx = scroll_x + visible_idx; + let is_sorted = self.sort_column_index == Some(actual_col_idx); + + let header_text = if is_sorted { + let indicator = match self.sort_order { + SortOrder::Ascending => " ↑", + SortOrder::Descending => " ↓", + SortOrder::None => "", + }; + format!("{}{}", col, indicator) + } else { + col.clone() + }; + + Cell::from(header_text) + }) + .collect(); + + // Build column indicator + let col_indicator = if total_cols > visible_cols { + let left_arrow = if scroll_x > 0 { "← " } else { " " }; + let right_arrow = if end_col < total_cols { " →" } else { " " }; + format!( + " {}Col {}-{}/{}{}", + left_arrow, + scroll_x + 1, + end_col, + total_cols, + right_arrow + ) + } else { + String::new() + }; + + // Build page indicator + let page_indicator = if total_pages > 1 { + format!( + " Page {}/{} (rows {}-{} of {})", + self.result_page + 1, + total_pages, + page_start + 1, + page_end, + total_rows + ) + } else { + format!(" {} rows", total_rows) + }; + + // Build sort indicator for title + let sort_indicator = if let Some((col_name, order)) = self.get_sort_info() { + let arrow = match order { + SortOrder::Ascending => "↑", + SortOrder::Descending => "↓", + SortOrder::None => "", + }; + format!(" [Sort: {} {}]", col_name, arrow) + } else { + String::new() + }; + + let table = Table::new(rows, constraints) + .header( + Row::new(header_cells) + .style(header_style()) + .bottom_margin(1), + ) + .block( + Block::default() + .title(format!( + " {}{}{}{}", + title_prefix, page_indicator, col_indicator, sort_indicator + )) + .borders(Borders::ALL) + .border_style(border_style(focused)), + ) + .row_highlight_style(result_row_highlight_style()) + .highlight_symbol(HIGHLIGHT_SYMBOL); + + frame.render_stateful_widget(table, area, &mut self.result_table_state); + + // Render overlays + if self.sort_mode_active { + self.render_sort_modal(frame, area); + } + if self.filter_mode_active { + self.render_filter_modal(frame, area); + } + if self.result_search_active { + self.render_result_search_bar(frame, area); + } + } +} diff --git a/crates/mbr-tui/src/components/content/views/welcome.rs b/crates/mbr-tui/src/components/content/views/welcome.rs new file mode 100644 index 0000000..d41b484 --- /dev/null +++ b/crates/mbr-tui/src/components/content/views/welcome.rs @@ -0,0 +1,126 @@ +//! Welcome screen and placeholder rendering. + +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; + +use crate::components::content::ContentPanel; + +impl ContentPanel { + /// Render welcome view content. + pub(in crate::components::content) fn render_welcome( + &self, + _area: Rect, + focused: bool, + ) -> Paragraph<'static> { + let border_style = if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + + let banner = r#" + _ _ _ + _ __ ___ | |__ _ __ | |_ _ _ (_) + | '_ ` _ \ | '_ \| '__|_____ | __| | | || | + | | | | | || |_) | | |_____|| |_| |_| || | + |_| |_| |_||_.__/|_| \__|\__,_||_| +"#; + + let mut lines: Vec = Vec::new(); + lines.push(Line::from("")); + + for banner_line in banner.lines() { + lines.push(Line::from(Span::styled( + banner_line.to_string(), + Style::default().fg(Color::Cyan), + ))); + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Welcome to mbr-tui!", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " Select an item from the navigation panel to get started.", + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::from("")); + lines.push(Line::from(" Quick Keys:")); + lines.push(Line::from(Span::styled( + " Tab - Switch panels", + Style::default().fg(Color::Yellow), + ))); + lines.push(Line::from(Span::styled( + " ↑/↓ j/k - Navigate items", + Style::default().fg(Color::Yellow), + ))); + lines.push(Line::from(Span::styled( + " Enter - Select item", + Style::default().fg(Color::Yellow), + ))); + lines.push(Line::from(Span::styled( + " q - Quit", + Style::default().fg(Color::Yellow), + ))); + + Paragraph::new(lines) + .block( + Block::default() + .title(" Welcome ") + .borders(Borders::ALL) + .border_style(border_style), + ) + .wrap(Wrap { trim: false }) + } + + /// Render placeholder for unimplemented views. + #[allow(dead_code)] + pub(in crate::components::content) fn render_placeholder( + &self, + title: &str, + focused: bool, + ) -> Paragraph<'static> { + let border_style = if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + + let lines = vec![ + Line::from(""), + Line::from(Span::styled( + format!(" {} view", title), + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + " ⚠ Not implemented yet", + Style::default().fg(Color::Yellow), + )), + Line::from(""), + Line::from(Span::styled( + " This feature is planned for future releases.", + Style::default().fg(Color::DarkGray), + )), + ]; + + Paragraph::new(lines) + .block( + Block::default() + .title(format!(" {} ", title)) + .borders(Borders::ALL) + .border_style(border_style), + ) + .wrap(Wrap { trim: false }) + } +}