Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/ui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ pub struct App {
pub sidebar_scroll: usize,
pub expanded_schemas: Vec<String>,
pub expanded_tables: Vec<String>,
pub sidebar_filter: String,
pub sidebar_filter_active: bool,

// Editor
pub editor: TextBuffer,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -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 {
Expand All @@ -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"));
}
}
70 changes: 63 additions & 7 deletions src/ui/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Constraint> = 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
Expand All @@ -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),
}
}

Expand All @@ -156,6 +188,7 @@ fn draw_databases_list(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = app
.databases
.iter()
.filter(|db| app.matches_sidebar_filter(&db.name))
.enumerate()
.map(|(i, db)| {
let style = if i == app.sidebar_selected {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 => "󰈈",
Expand Down Expand Up @@ -255,6 +308,7 @@ fn draw_history_list(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = entries
.iter()
.rev()
.filter(|entry| app.matches_sidebar_filter(&entry.query))
.enumerate()
.map(|(i, entry)| {
let status_icon = if entry.success { "✓" } else { "✗" };
Expand Down Expand Up @@ -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",
Expand Down