diff --git a/src/ui/app.rs b/src/ui/app.rs index cd3e4e5..dd02b5b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -113,6 +113,8 @@ pub struct App { pub sidebar_scroll: usize, pub expanded_schemas: Vec, pub expanded_tables: Vec, + pub sidebar_filter: String, + pub sidebar_filter_active: bool, // Editor pub editor: TextBuffer, @@ -392,6 +394,8 @@ impl App { sidebar_scroll: 0, expanded_schemas: vec!["public".to_string()], expanded_tables: Vec::new(), + sidebar_filter: String::new(), + sidebar_filter_active: false, editor: TextBuffer::new(), query_history, @@ -774,6 +778,35 @@ impl App { } async fn handle_sidebar_input(&mut self, key: KeyEvent) -> Result<()> { + // Handle filter input mode + if self.sidebar_filter_active { + match key.code { + KeyCode::Esc => { + self.sidebar_filter_active = false; + self.sidebar_filter.clear(); + self.sidebar_selected = 0; + } + KeyCode::Enter => { + self.sidebar_filter_active = false; + } + KeyCode::Backspace => { + if !self.sidebar_filter.is_empty() { + self.sidebar_filter.pop(); + self.sidebar_selected = 0; + } + if self.sidebar_filter.is_empty() { + self.sidebar_filter_active = false; + } + } + KeyCode::Char(c) => { + self.sidebar_filter.push(c); + self.sidebar_selected = 0; + } + _ => {} + } + return Ok(()); + } + match key.code { KeyCode::Tab | KeyCode::Right => { self.focus = Focus::Editor; @@ -815,6 +848,19 @@ impl App { KeyCode::Char('i') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.open_table_inspector().await; } + KeyCode::Char('/') => { + self.sidebar_filter_active = true; + self.sidebar_filter.clear(); + self.sidebar_selected = 0; + } + KeyCode::Esc => { + if !self.sidebar_filter.is_empty() { + self.sidebar_filter.clear(); + self.sidebar_selected = 0; + } else { + self.focus = Focus::Editor; + } + } _ => {} } Ok(()) @@ -1878,6 +1924,15 @@ impl App { Ok(()) } + + /// Check if a string matches the current sidebar filter (case-insensitive substring). + pub fn matches_sidebar_filter(&self, text: &str) -> bool { + if self.sidebar_filter.is_empty() { + return true; + } + text.to_lowercase() + .contains(&self.sidebar_filter.to_lowercase()) + } } fn dialog_field_len(config: &ConnectionConfig, field_index: usize) -> usize { @@ -1891,3 +1946,37 @@ fn dialog_field_len(config: &ConnectionConfig, field_index: usize) -> usize { _ => 0, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_matches_sidebar_filter_empty() { + let app = App::new(); + assert!(app.matches_sidebar_filter("anything")); + } + + #[test] + fn test_matches_sidebar_filter_substring() { + let mut app = App::new(); + app.sidebar_filter = "user".to_string(); + assert!(app.matches_sidebar_filter("users")); + assert!(app.matches_sidebar_filter("user_roles")); + assert!(app.matches_sidebar_filter("active_users")); + assert!(!app.matches_sidebar_filter("orders")); + + app.sidebar_filter = "usr".to_string(); + assert!(app.matches_sidebar_filter("pg_usr_table")); + assert!(!app.matches_sidebar_filter("users")); + } + + #[test] + fn test_matches_sidebar_filter_case_insensitive() { + let mut app = App::new(); + app.sidebar_filter = "User".to_string(); + assert!(app.matches_sidebar_filter("users")); + assert!(app.matches_sidebar_filter("USERS")); + assert!(app.matches_sidebar_filter("User_Data")); + } +} diff --git a/src/ui/components.rs b/src/ui/components.rs index fdae4d5..2ba8dca 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -109,12 +109,23 @@ fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) { let theme = &app.theme; let focused = app.focus == Focus::Sidebar; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ + let has_filter = !app.sidebar_filter.is_empty() || app.sidebar_filter_active; + let constraints: Vec = if has_filter { + vec![ Constraint::Length(3), // Tabs + Constraint::Length(1), // Filter bar Constraint::Min(0), // Content - ]) + ] + } else { + vec![ + Constraint::Length(3), // Tabs + Constraint::Min(0), // Content + ] + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) .split(area); // Draw tabs @@ -141,11 +152,32 @@ fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(tabs, chunks[0]); + // Draw filter bar if active + if has_filter { + let filter_text = if app.sidebar_filter.is_empty() { + " / type to filter...".to_string() + } else { + format!(" /{}", app.sidebar_filter) + }; + let filter_style = if app.sidebar_filter_active { + Style::default() + .fg(theme.text_accent) + .bg(theme.bg_secondary) + } else { + Style::default() + .fg(theme.text_secondary) + .bg(theme.bg_secondary) + }; + let filter_bar = Paragraph::new(filter_text).style(filter_style); + frame.render_widget(filter_bar, chunks[1]); + } + // Draw content based on selected tab + let content_area = *chunks.last().unwrap(); match app.sidebar_tab { - SidebarTab::Databases => draw_databases_list(frame, app, chunks[1]), - SidebarTab::Tables => draw_tables_tree(frame, app, chunks[1]), - SidebarTab::History => draw_history_list(frame, app, chunks[1]), + SidebarTab::Databases => draw_databases_list(frame, app, content_area), + SidebarTab::Tables => draw_tables_tree(frame, app, content_area), + SidebarTab::History => draw_history_list(frame, app, content_area), } } @@ -156,6 +188,7 @@ fn draw_databases_list(frame: &mut Frame, app: &App, area: Rect) { let items: Vec = app .databases .iter() + .filter(|db| app.matches_sidebar_filter(&db.name)) .enumerate() .map(|(i, db)| { let style = if i == app.sidebar_selected { @@ -198,6 +231,18 @@ fn draw_tables_tree(frame: &mut Frame, app: &App, area: Rect) { let expanded = app.expanded_schemas.contains(&schema.name); let icon = if expanded { "▼" } else { "▶" }; + // Skip schemas with no matching tables when filter is active + let schema_has_matches = app.sidebar_filter.is_empty() + || app.matches_sidebar_filter(&schema.name) + || app + .tables + .iter() + .any(|t| t.schema == schema.name && app.matches_sidebar_filter(&t.name)); + + if !schema_has_matches { + continue; + } + let style = if index == app.sidebar_selected { theme.selected() } else { @@ -210,6 +255,14 @@ fn draw_tables_tree(frame: &mut Frame, app: &App, area: Rect) { if expanded { for table in &app.tables { if table.schema == schema.name { + // Apply filter + if !app.sidebar_filter.is_empty() + && !app.matches_sidebar_filter(&table.name) + && !app.matches_sidebar_filter(&schema.name) + { + continue; + } + let table_icon = match table.table_type { crate::db::TableType::Table => "󰓫", crate::db::TableType::View => "󰈈", @@ -255,6 +308,7 @@ fn draw_history_list(frame: &mut Frame, app: &App, area: Rect) { let items: Vec = entries .iter() .rev() + .filter(|entry| app.matches_sidebar_filter(&entry.query)) .enumerate() .map(|(i, entry)| { let status_icon = if entry.success { "✓" } else { "✗" }; @@ -1507,6 +1561,8 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " 1/2/3 Switch tabs", " Enter Select item", " ↑/↓ Navigate", + " / Search/filter", + " Esc Clear filter", " Ctrl+I Inspect table (DDL)", "", " RESULTS",