From 4486fc707d4c9ca69f0e0260429ce877e71748dc Mon Sep 17 00:00:00 2001 From: Takayuki Maeda <41065217+TaKO8Ki@users.noreply.github.com> Date: Sun, 11 Jul 2021 01:10:46 +0900 Subject: [PATCH] Filter tables (#20) * filter databases * implement utils * remove filter when input is empty * remove focus shortcut * add tests for databasetree * fix clippy warnings * fix placeholder --- database-tree/src/databasetree.rs | 164 ++++++++++++++++++++++++ database-tree/src/databasetreeitems.rs | 21 ++++ database-tree/src/item.rs | 24 ++++ src/components/databases.rs | 168 +++++++++++++++++++++---- src/components/error.rs | 2 +- src/handlers/connection_list.rs | 18 +-- src/handlers/database_list.rs | 11 +- src/handlers/mod.rs | 10 +- 8 files changed, 371 insertions(+), 47 deletions(-) diff --git a/database-tree/src/databasetree.rs b/database-tree/src/databasetree.rs index 7d126c6..844691a 100644 --- a/database-tree/src/databasetree.rs +++ b/database-tree/src/databasetree.rs @@ -44,6 +44,16 @@ impl DatabaseTree { Ok(new_self) } + pub fn filter(&self, filter_text: String) -> Self { + let mut new_self = Self { + items: self.items.filter(filter_text), + selection: Some(0), + visual_selection: None, + }; + new_self.visual_selection = new_self.calc_visual_selection(); + new_self + } + pub fn collapse_but_root(&mut self) { self.items.collapse(0, true); self.items.expand(0, false); @@ -282,3 +292,157 @@ impl DatabaseTree { .unwrap_or_default() } } + +#[cfg(test)] +mod test { + use crate::{Database, DatabaseTree, MoveSelection, Table}; + // use pretty_assertions::assert_eq; + use std::collections::BTreeSet; + + impl Table { + fn new(name: String) -> Self { + Table { + name, + create_time: None, + update_time: None, + engine: None, + } + } + } + + #[test] + fn test_selection() { + let items = vec![Database::new( + "a".to_string(), + vec![Table::new("b".to_string())], + )]; + + // a + // b + + let mut tree = DatabaseTree::new(&items, &BTreeSet::new()).unwrap(); + + assert!(tree.move_selection(MoveSelection::Right)); + assert_eq!(tree.selection, Some(0)); + assert!(tree.move_selection(MoveSelection::Down)); + assert_eq!(tree.selection, Some(1)); + } + + #[test] + fn test_selection_skips_collapsed() { + let items = vec![ + Database::new( + "a".to_string(), + vec![Table::new("b".to_string()), Table::new("c".to_string())], + ), + Database::new("d".to_string(), vec![Table::new("e".to_string())]), + ]; + + // a + // b + // c + // d + // e + + let mut tree = DatabaseTree::new(&items, &BTreeSet::new()).unwrap(); + + tree.items.collapse(0, false); + tree.selection = Some(1); + + assert!(tree.move_selection(MoveSelection::Down)); + assert_eq!(tree.selection, Some(3)); + } + + #[test] + fn test_selection_left_collapse() { + let items = vec![Database::new( + "a".to_string(), + vec![Table::new("b".to_string()), Table::new("c".to_string())], + )]; + + // a + // b + // c + + let mut tree = DatabaseTree::new(&items, &BTreeSet::new()).unwrap(); + tree.selection = Some(0); + tree.items.expand(0, false); + + assert!(tree.move_selection(MoveSelection::Left)); + assert_eq!(tree.selection, Some(0)); + assert!(tree.items.tree_items[0].kind().is_database_collapsed()); + assert!(!tree.items.tree_items[1].info().is_visible()); + assert!(!tree.items.tree_items[2].info().is_visible()); + } + + #[test] + fn test_selection_left_parent() { + let items = vec![Database::new( + "a".to_string(), + vec![Table::new("b".to_string()), Table::new("c".to_string())], + )]; + + // a + // b + // c + + let mut tree = DatabaseTree::new(&items, &BTreeSet::new()).unwrap(); + tree.selection = Some(2); + tree.items.expand(0, false); + + assert!(tree.move_selection(MoveSelection::Left)); + assert_eq!(tree.selection, Some(0)); + } + + #[test] + fn test_selection_right_expand() { + let items = vec![Database::new( + "a".to_string(), + vec![Table::new("b".to_string()), Table::new("c".to_string())], + )]; + + // a + // b + // c + + let mut tree = DatabaseTree::new(&items, &BTreeSet::new()).unwrap(); + + tree.selection = Some(0); + + assert!(tree.move_selection(MoveSelection::Right)); + assert_eq!(tree.selection, Some(0)); + assert!(!tree.items.tree_items[0].kind().is_database_collapsed()); + + assert!(tree.move_selection(MoveSelection::Right)); + assert_eq!(tree.selection, Some(1)); + } + + #[test] + fn test_visible_selection() { + let items = vec![ + Database::new( + "a".to_string(), + vec![Table::new("b".to_string()), Table::new("c".to_string())], + ), + Database::new("d".to_string(), vec![Table::new("e".to_string())]), + ]; + + // a + // b + // c + // d + // e + + let mut tree = DatabaseTree::new(&items, &BTreeSet::new()).unwrap(); + tree.items.expand(0, false); + tree.items.expand(3, false); + + tree.selection = Some(0); + assert!(tree.move_selection(MoveSelection::Left)); + assert!(tree.move_selection(MoveSelection::Down)); + let s = tree.visual_selection().unwrap(); + + assert_eq!(s.count, 3); + assert_eq!(s.index, 1); + } +} diff --git a/database-tree/src/databasetreeitems.rs b/database-tree/src/databasetreeitems.rs index 69db1dd..d0fedb9 100644 --- a/database-tree/src/databasetreeitems.rs +++ b/database-tree/src/databasetreeitems.rs @@ -19,6 +19,27 @@ impl DatabaseTreeItems { }) } + pub fn filter(&self, filter_text: String) -> Self { + Self { + tree_items: self + .tree_items + .iter() + .filter(|item| item.is_database() || item.is_match(&filter_text)) + .map(|item| { + if item.is_database() { + let mut item = item.clone(); + item.set_collapsed(false); + item + } else { + let mut item = item.clone(); + item.show(); + item + } + }) + .collect::>(), + } + } + fn create_items( list: &[Database], collapsed: &BTreeSet<&String>, diff --git a/database-tree/src/item.rs b/database-tree/src/item.rs index 27a237c..a3cc268 100644 --- a/database-tree/src/item.rs +++ b/database-tree/src/item.rs @@ -98,6 +98,15 @@ impl DatabaseTreeItem { }) } + pub fn set_collapsed(&mut self, collapsed: bool) { + if let DatabaseTreeItemKind::Database { name, .. } = self.kind() { + self.kind = DatabaseTreeItemKind::Database { + name: name.to_string(), + collapsed, + } + } + } + pub const fn info(&self) -> &TreeItemInfo { &self.info } @@ -128,9 +137,24 @@ impl DatabaseTreeItem { } } + pub fn show(&mut self) { + self.info.visible = true; + } + pub fn hide(&mut self) { self.info.visible = false; } + + pub fn is_match(&self, filter_text: &str) -> bool { + match self.kind.clone() { + DatabaseTreeItemKind::Database { name, .. } => name.contains(filter_text), + DatabaseTreeItemKind::Table { table, .. } => table.name.contains(filter_text), + } + } + + pub fn is_database(&self) -> bool { + self.kind.is_database() + } } impl Eq for DatabaseTreeItem {} diff --git a/src/components/databases.rs b/src/components/databases.rs index 1e38519..6065a4d 100644 --- a/src/components/databases.rs +++ b/src/components/databases.rs @@ -3,16 +3,19 @@ use crate::event::Key; use crate::ui::common_nav; use crate::ui::scrolllist::draw_list_block; use anyhow::Result; -use database_tree::{DatabaseTree, DatabaseTreeItem}; +use database_tree::{Database, DatabaseTree, DatabaseTreeItem}; +use std::collections::BTreeSet; use std::convert::From; use tui::{ backend::Backend, layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, + symbols::line::HORIZONTAL, text::Span, widgets::{Block, Borders}, Frame, }; +use unicode_width::UnicodeWidthStr; // ▸ const FOLDER_ICON_COLLAPSED: &str = "\u{25b8}"; @@ -20,20 +23,61 @@ const FOLDER_ICON_COLLAPSED: &str = "\u{25b8}"; const FOLDER_ICON_EXPANDED: &str = "\u{25be}"; const EMPTY_STR: &str = ""; +pub enum FocusBlock { + Filter, + Tree, +} + pub struct DatabasesComponent { pub tree: DatabaseTree, + pub filterd_tree: Option, pub scroll: VerticalScroll, + pub input: String, + pub input_cursor_x: u16, + pub focus_block: FocusBlock, } impl DatabasesComponent { pub fn new() -> Self { Self { tree: DatabaseTree::default(), + filterd_tree: None, scroll: VerticalScroll::new(), + input: String::new(), + input_cursor_x: 0, + focus_block: FocusBlock::Tree, + } + } + + pub fn update(&mut self, list: &[Database], collapsed: &BTreeSet<&String>) -> Result<()> { + self.tree = DatabaseTree::new(list, collapsed)?; + self.filterd_tree = None; + self.input = String::new(); + self.input_cursor_x = 0; + Ok(()) + } + + pub fn tree_focused(&self) -> bool { + matches!(self.focus_block, FocusBlock::Tree) + } + + pub fn tree(&self) -> &DatabaseTree { + self.filterd_tree.as_ref().unwrap_or(&self.tree) + } + + pub fn increment_input_cursor_x(&mut self) { + if self.input_cursor_x > 0 { + self.input_cursor_x -= 1; + } + } + + pub fn decrement_input_cursor_x(&mut self) { + if self.input_cursor_x < self.input.width() as u16 { + self.input_cursor_x += 1; } } - fn tree_item_to_span(item: &DatabaseTreeItem, selected: bool, width: u16) -> Span<'_> { + fn tree_item_to_span(item: DatabaseTreeItem, selected: bool, width: u16) -> Span<'static> { let name = item.kind().name(); let indent = item.info().indent(); @@ -72,21 +116,59 @@ impl DatabasesComponent { } fn draw_tree(&self, f: &mut Frame, area: Rect, focused: bool) { - let tree_height = usize::from(area.height.saturating_sub(2)); - self.tree.visual_selection().map_or_else( + let tree_height = usize::from(area.height.saturating_sub(4)); + let tree = if let Some(tree) = self.filterd_tree.as_ref() { + tree + } else { + &self.tree + }; + tree.visual_selection().map_or_else( || { self.scroll.reset(); }, |selection| { - self.scroll - .update(selection.index, selection.count, tree_height); + self.scroll.update( + selection.index, + selection.count.saturating_sub(2), + tree_height, + ); }, ); - let items = self - .tree + let mut items = tree .iterate(self.scroll.get_top(), tree_height) - .map(|(item, selected)| Self::tree_item_to_span(item, selected, area.width)); + .map(|(item, selected)| Self::tree_item_to_span(item.clone(), selected, area.width)) + .collect::>(); + + items.insert( + 0, + Span::styled( + (0..area.width as usize) + .map(|_| HORIZONTAL) + .collect::>() + .join(""), + Style::default(), + ), + ); + items.insert( + 0, + Span::styled( + format!( + "{}{:w$}", + if self.input.is_empty() && matches!(self.focus_block, FocusBlock::Tree) { + " / to filter tables".to_string() + } else { + self.input.clone() + }, + w = area.width as usize + ), + if let FocusBlock::Filter = self.focus_block { + Style::default() + } else { + Style::default().fg(Color::DarkGray) + }, + ), + ); let title = "Databases"; draw_list_block( @@ -101,39 +183,79 @@ impl DatabasesComponent { }) .borders(Borders::ALL) .border_style(Style::default()), - items, + items.into_iter(), ); self.scroll.draw(f, area); + if let FocusBlock::Filter = self.focus_block { + f.set_cursor( + area.x + self.input.width() as u16 + 1 - self.input_cursor_x, + area.y + 1, + ) + } } } impl DrawableComponent for DatabasesComponent { fn draw(&mut self, f: &mut Frame, area: Rect, focused: bool) -> Result<()> { - if true { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(100)].as_ref()) - .split(area); + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(100)].as_ref()) + .split(area); - self.draw_tree(f, chunks[0], focused); - } + self.draw_tree(f, chunks[0], focused); Ok(()) } } impl Component for DatabasesComponent { fn event(&mut self, key: Key) -> Result<()> { - if tree_nav(&mut self.tree, key) { - return Ok(()); + match key { + Key::Char('/') if matches!(self.focus_block, FocusBlock::Tree) => { + self.focus_block = FocusBlock::Filter + } + Key::Char(c) if matches!(self.focus_block, FocusBlock::Filter) => { + self.input.push(c); + self.filterd_tree = Some(self.tree.filter(self.input.clone())) + } + Key::Delete | Key::Backspace => { + if !self.input.is_empty() { + if self.input_cursor_x == 0 { + self.input.pop(); + } else if self.input.width() - self.input_cursor_x as usize > 0 { + self.input.remove( + self.input + .width() + .saturating_sub(self.input_cursor_x as usize) + .saturating_sub(1), + ); + } + self.filterd_tree = if self.input.is_empty() { + None + } else { + Some(self.tree.filter(self.input.clone())) + } + } + } + Key::Left => self.decrement_input_cursor_x(), + Key::Right => self.increment_input_cursor_x(), + Key::Enter if matches!(self.focus_block, FocusBlock::Filter) => { + self.focus_block = FocusBlock::Tree + } + key => tree_nav( + if let Some(tree) = self.filterd_tree.as_mut() { + tree + } else { + &mut self.tree + }, + key, + ), } Ok(()) } } -fn tree_nav(tree: &mut DatabaseTree, key: Key) -> bool { +fn tree_nav(tree: &mut DatabaseTree, key: Key) { if let Some(common_nav) = common_nav(key) { - tree.move_selection(common_nav) - } else { - false + tree.move_selection(common_nav); } } diff --git a/src/components/error.rs b/src/components/error.rs index d83af84..0230849 100644 --- a/src/components/error.rs +++ b/src/components/error.rs @@ -49,7 +49,7 @@ impl DrawableComponent for ErrorComponent { } impl Component for ErrorComponent { - fn event(&mut self, key: Key) -> Result<()> { + fn event(&mut self, _key: Key) -> Result<()> { Ok(()) } } diff --git a/src/handlers/connection_list.rs b/src/handlers/connection_list.rs index 822d5e6..ba4dd1a 100644 --- a/src/handlers/connection_list.rs +++ b/src/handlers/connection_list.rs @@ -2,7 +2,7 @@ use crate::app::{App, FocusBlock}; use crate::components::Component as _; use crate::event::Key; use crate::utils::{get_databases, get_tables}; -use database_tree::{Database, DatabaseTree}; +use database_tree::Database; use sqlx::mysql::MySqlPool; use std::collections::BTreeSet; @@ -21,23 +21,23 @@ pub async fn handler(key: Key, app: &mut App) -> anyhow::Result<()> { } if let Some(conn) = app.connections.selected_connection() { match &conn.database { - Some(database) => { - app.databases.tree = DatabaseTree::new( + Some(database) => app + .databases + .update( &[Database::new( database.clone(), get_tables(database.clone(), app.pool.as_ref().unwrap()).await?, )], &BTreeSet::new(), ) - .unwrap() - } - None => { - app.databases.tree = DatabaseTree::new( + .unwrap(), + None => app + .databases + .update( get_databases(app.pool.as_ref().unwrap()).await?.as_slice(), &BTreeSet::new(), ) - .unwrap() - } + .unwrap(), } }; } diff --git a/src/handlers/database_list.rs b/src/handlers/database_list.rs index c8f4b9f..88ca64c 100644 --- a/src/handlers/database_list.rs +++ b/src/handlers/database_list.rs @@ -6,11 +6,12 @@ use database_tree::Database; pub async fn handler(key: Key, app: &mut App) -> anyhow::Result<()> { match key { - Key::Esc => app.focus_block = FocusBlock::DabataseList, - Key::Right => app.focus_block = FocusBlock::Table, - Key::Char('c') => app.focus_block = FocusBlock::ConnectionList, - Key::Enter => { - if let Some((table, database)) = app.databases.tree.selected_table() { + Key::Char('c') if app.databases.tree_focused() => { + app.focus_block = FocusBlock::ConnectionList + } + Key::Right if app.databases.tree_focused() => app.focus_block = FocusBlock::Table, + Key::Enter if app.databases.tree_focused() => { + if let Some((table, database)) = app.databases.tree().selected_table() { app.focus_block = FocusBlock::Table; let (headers, records) = get_records( &Database { diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index c2fb900..bc7cbc1 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -11,15 +11,7 @@ use crate::event::Key; pub async fn handle_app(key: Key, app: &mut App) -> anyhow::Result<()> { match key { - Key::Char('d') => match app.focus_block { - FocusBlock::Query => (), - _ => app.focus_block = FocusBlock::DabataseList, - }, - Key::Char('r') => match app.focus_block { - FocusBlock::Query => (), - _ => app.focus_block = FocusBlock::Table, - }, - Key::Char('e') => app.focus_block = FocusBlock::Query, + Key::Ctrl('e') => app.focus_block = FocusBlock::Query, Key::Esc if app.error.error.is_some() => { app.error.error = None; return Ok(());