From 26a2cf55d253ff8289c436fb9c9bb61a75790fe4 Mon Sep 17 00:00:00 2001 From: Jack Wills <32690432+mrjackwills@users.noreply.github.com> Date: Wed, 24 Jul 2024 14:20:29 +0000 Subject: [PATCH] feat: advanced filtering Allow filtering by name, image name, status, or a combination of all of the three --- src/app_data/container_state.rs | 4 + src/app_data/mod.rs | 288 ++++++++++++++++++++++++++++---- src/input_handler/mod.rs | 6 + src/main.rs | 6 +- src/ui/draw_blocks.rs | 141 +++++++++++++--- 5 files changed, 390 insertions(+), 55 deletions(-) diff --git a/src/app_data/container_state.rs b/src/app_data/container_state.rs index 0dcee3e..ad834ee 100644 --- a/src/app_data/container_state.rs +++ b/src/app_data/container_state.rs @@ -75,6 +75,10 @@ macro_rules! unit_struct { pub fn set(&mut self, value: String) { self.0 = value; } + + pub fn contains(&self, term: &str) -> bool { + self.0.to_lowercase().contains(term) + } } impl std::fmt::Display for $name { diff --git a/src/app_data/mod.rs b/src/app_data/mod.rs index d279384..2fa3bb7 100644 --- a/src/app_data/mod.rs +++ b/src/app_data/mod.rs @@ -55,26 +55,85 @@ impl fmt::Display for Header { } } +#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum FilterBy { + #[default] + Name, + Image, + Status, + All, +} + +/// Convert errors into strings to display +impl fmt::Display for FilterBy { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Name => "Name", + Self::Image => "Image", + Self::Status => "Status", + Self::All => "All", + } + ) + } +} + +impl FilterBy { + const fn next(self) -> Option { + match self { + Self::Name => Some(Self::Image), + Self::Image => Some(Self::Status), + Self::Status => Some(Self::All), + Self::All => None, + } + } + + const fn prev(self) -> Option { + match self { + Self::Name => None, + Self::Image => Some(Self::Name), + Self::Status => Some(Self::Image), + Self::All => Some(Self::Status), + } + } +} + +#[derive(Debug, Clone)] +pub struct Filter { + pub term: Option, + pub by: FilterBy, +} +impl Filter { + pub fn new() -> Self { + Self { + term: None, + by: FilterBy::default(), + } + } +} + /// Global app_state, stored in an Arc #[derive(Debug, Clone)] #[cfg(not(test))] pub struct AppData { containers: StatefulList, error: Option, - filter_term: Option, - sorted_by: Option<(Header, SortedOrder)>, + filter: Filter, hidden_containers: Vec, + sorted_by: Option<(Header, SortedOrder)>, pub args: CliArgs, } #[derive(Debug, Clone)] #[cfg(test)] pub struct AppData { - pub hidden_containers: Vec, pub args: CliArgs, pub containers: StatefulList, pub error: Option, - pub filter_term: Option, + pub filter: Filter, + pub hidden_containers: Vec, pub sorted_by: Option<(Header, SortedOrder)>, } @@ -87,7 +146,7 @@ impl AppData { hidden_containers: vec![], error: None, sorted_by: None, - filter_term: None, + filter: Filter::new(), } } @@ -104,20 +163,33 @@ impl AppData { /// Get the current filter term pub const fn get_filter_term(&self) -> Option<&String> { - self.filter_term.as_ref() + self.filter.term.as_ref() } - /// Check the container name against the current filter - fn can_insert(&self, name: &str) -> bool { - self.filter_term.as_ref().map_or(true, |term| { - name.to_string() - .to_lowercase() - .contains(&term.to_lowercase()) + /// Get the current filter by choice + pub const fn get_filter_by(&self) -> FilterBy { + self.filter.by + } + + /// Check if a given container can be inserted into the "visible" list, based on current filter term and filter_by + fn can_insert(&self, container: &ContainerItem) -> bool { + self.filter.term.as_ref().map_or(true, |term| { + let term = term.to_lowercase(); + match self.filter.by { + FilterBy::All => { + container.name.contains(&term) + || container.image.contains(&term) + || container.status.to_lowercase().contains(&term) + } + FilterBy::Image => container.image.contains(&term), + FilterBy::Name => container.name.contains(&term), + FilterBy::Status => container.status.to_lowercase().contains(&term), + } }) } /// Remove items from the containers list based on the filter term, and insert into a "hidden" vec - /// sets the state to start if any filtering has occured + /// sets the state to start if any filtering has occurred /// Also search in the "hidden" vec for items and insert back into the main containers vec fn filter_containers(&mut self) { let pre_len = self.get_container_len(); @@ -127,7 +199,7 @@ impl AppData { .hidden_containers .iter() .cloned() - .partition(|item| self.can_insert(item.name.get())); + .partition(|item| self.can_insert(item)); while let Some(x) = new_items.pop() { self.containers.items.push(x); @@ -140,7 +212,7 @@ impl AppData { .items .iter() .cloned() - .partition(|item| self.can_insert(item.name.get())); + .partition(|item| self.can_insert(item)); self.containers.items = new_items; self.hidden_containers.extend(tmp_items); @@ -151,31 +223,54 @@ impl AppData { } } + /// Re-filter the containers, used after the filter.by has been changed + fn re_filter(&mut self) { + self.containers.items.append(&mut self.hidden_containers); + self.hidden_containers = vec![]; + self.filter_containers(); + } + /// Set a single char into the filter term pub fn filter_term_push(&mut self, c: char) { - if let Some(term) = self.filter_term.as_mut() { + if let Some(term) = self.filter.term.as_mut() { term.push(c); } else { - self.filter_term = Some(format!("{c}")); + self.filter.term = Some(format!("{c}")); }; self.filter_containers(); } /// Delete the final char of the filter term pub fn filter_term_pop(&mut self) { - if let Some(term) = self.filter_term.as_mut() { + if let Some(term) = self.filter.term.as_mut() { // should now search for items in the tmp vec, and insert into containers if found term.pop(); if term.is_empty() { - self.filter_term = None; + self.filter.term = None; } } self.filter_containers(); } - /// Remove the filter term completely, empty the "hidden" container vec + // change the filter_by option + pub fn filter_by_next(&mut self) { + if let Some(by) = self.filter.by.next() { + self.filter.by = by; + self.re_filter(); + } + } + + // change the filter_by option + pub fn filter_by_prev(&mut self) { + if let Some(by) = self.filter.by.prev() { + self.filter.by = by; + self.re_filter(); + } + } + + /// Remove the filter completely pub fn filter_term_clear(&mut self) { - self.filter_term = None; + self.filter.term = None; while let Some(i) = self.hidden_containers.pop() { if self.get_container_by_id(&i.id).is_none() { self.containers.items.push(i); @@ -307,9 +402,14 @@ impl AppData { &self.containers.items } - /// Get title for containers section + /// Get title for containers section, add a suffix indicating if the containers are currently under filter pub fn container_title(&self) -> String { - self.containers.get_state_title() + let suffix = if !self.hidden_containers.is_empty() && !self.containers.items.is_empty() { + " - filtered" + } else { + "" + }; + format!("{}{}", self.containers.get_state_title(), suffix) } /// Select the first container @@ -595,7 +695,7 @@ impl AppData { /// Find the widths for the strings in the containers panel. /// So can display nicely and evenly - /// Searches in both containes & hidden_containers + /// Searches in both contains & hidden_containers pub fn get_width(&self) -> Columns { let mut columns = Columns::new(); let count = |x: &str| u8::try_from(x.chars().count()).unwrap_or(12); @@ -777,10 +877,10 @@ impl AppData { }; } else { // container not known, so make new ContainerItem and push into containers Ve - let can_insert = self.can_insert(&name); let container = ContainerItem::new( created, id, image, is_oxker, name, ports, state, status, ); + let can_insert = self.can_insert(&container); if can_insert { self.containers.items.push(container); } else { @@ -1551,8 +1651,8 @@ mod tests { // ****** // #[test] - /// Data is filtered correctly - fn test_app_data_filter() { + /// Data is filtered correctly by name + fn test_app_data_filter_by_name() { let (_, containers) = gen_containers(); let mut app_data = gen_appdata(&containers); @@ -1571,9 +1671,137 @@ mod tests { assert_eq!(post_len, 1); // Can insert checks against the current filter term - assert!(app_data.can_insert("_2")); - assert!(!app_data.can_insert("_")); - assert!(!app_data.can_insert("_3")); + // todo!("fix me"); + assert!(app_data.can_insert(&containers[1])); + assert!(!app_data.can_insert(&containers[0])); + assert!(!app_data.can_insert(&containers[2])); + } + + #[test] + /// Data is filtered correctly by image + fn test_app_data_filter_by_image() { + let (_, containers) = gen_containers(); + + let mut app_data = gen_appdata(&containers); + + assert!(app_data.get_filter_term().is_none()); + + let pre_len = app_data.containers.items.len(); + for c in ['i', 'm', 'a', 'g', 'e', '_', '2'] { + app_data.filter_term_push(c); + } + // app_data.filter_term_push('2'); + app_data.filter_by_next(); + + assert_eq!(app_data.get_filter_by(), FilterBy::Image); + assert_eq!(app_data.get_filter_term(), Some(&"image_2".to_string())); + + app_data.filter_containers(); + let post_len = app_data.containers.items.len(); + assert!(pre_len != post_len); + assert_eq!(post_len, 1); + + assert!(!app_data.can_insert(&containers[0])); + assert!(app_data.can_insert(&containers[1])); + assert!(!app_data.can_insert(&containers[2])); + } + + #[test] + /// Data is filtered correctly by status + fn test_app_data_filter_by_status() { + let (_, mut containers) = gen_containers(); + "Exited".clone_into(&mut containers[0].status); + let mut app_data = gen_appdata(&containers); + + assert!(app_data.get_filter_term().is_none()); + + let pre_len = app_data.containers.items.len(); + app_data.filter_term_push('x'); + + app_data.filter_by_next(); + app_data.filter_by_next(); + + assert_eq!(app_data.get_filter_by(), FilterBy::Status); + assert_eq!(app_data.get_filter_term(), Some(&"x".to_string())); + + app_data.filter_containers(); + let post_len = app_data.containers.items.len(); + assert!(pre_len != post_len); + assert_eq!(post_len, 1); + + assert!(app_data.can_insert(&containers[0])); + assert!(!app_data.can_insert(&containers[1])); + assert!(!app_data.can_insert(&containers[2])); + } + + #[test] + /// Data is filtered correctly by all + fn test_app_data_filter_by_all() { + let (_, mut containers) = gen_containers(); + "Exited".clone_into(&mut containers[0].status); + let mut app_data = gen_appdata(&containers); + + assert!(app_data.get_filter_term().is_none()); + + let pre_len = app_data.containers.items.len(); + app_data.filter_term_push('x'); + + app_data.filter_by_next(); + app_data.filter_by_next(); + app_data.filter_by_next(); + + assert_eq!(app_data.get_filter_by(), FilterBy::All); + assert_eq!(app_data.get_filter_term(), Some(&"x".to_string())); + + app_data.filter_containers(); + let post_len = app_data.containers.items.len(); + assert!(pre_len != post_len); + assert_eq!(post_len, 1); + + assert!(app_data.can_insert(&containers[0])); + assert!(!app_data.can_insert(&containers[1])); + assert!(!app_data.can_insert(&containers[2])); + } + + #[test] + /// Data is filtered correctly after various next() and previous() commands + fn test_app_data_filter_prev() { + let (_, mut containers) = gen_containers(); + "Exited".clone_into(&mut containers[0].status); + let mut app_data = gen_appdata(&containers); + + assert!(app_data.get_filter_term().is_none()); + + let pre_len = app_data.containers.items.len(); + app_data.filter_term_push('x'); + + app_data.filter_by_next(); + app_data.filter_by_next(); + + assert_eq!(app_data.get_filter_by(), FilterBy::Status); + assert_eq!(app_data.get_filter_term(), Some(&"x".to_string())); + + app_data.filter_containers(); + let post_len = app_data.containers.items.len(); + assert!(pre_len != post_len); + assert_eq!(post_len, 1); + + assert!(app_data.can_insert(&containers[0])); + assert!(!app_data.can_insert(&containers[1])); + assert!(!app_data.can_insert(&containers[2])); + + app_data.filter_by_prev(); + assert_eq!(app_data.get_filter_by(), FilterBy::Image); + assert_eq!(app_data.get_filter_term(), Some(&"x".to_string())); + + app_data.filter_containers(); + let post_len = app_data.containers.items.len(); + assert!(pre_len != post_len); + assert_eq!(post_len, 0); + + assert!(!app_data.can_insert(&containers[0])); + assert!(!app_data.can_insert(&containers[1])); + assert!(!app_data.can_insert(&containers[2])); } // **** // diff --git a/src/input_handler/mod.rs b/src/input_handler/mod.rs index edf2c4c..85578a2 100644 --- a/src/input_handler/mod.rs +++ b/src/input_handler/mod.rs @@ -401,6 +401,12 @@ impl InputHandler { KeyCode::Char(x) => { self.app_data.lock().filter_term_push(x); } + KeyCode::Right => { + self.app_data.lock().filter_by_next(); + } + KeyCode::Left => { + self.app_data.lock().filter_by_prev(); + } _ => (), } } diff --git a/src/main.rs b/src/main.rs index 9ef3fee..aeff8a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -175,7 +175,9 @@ mod tests { use bollard::service::{ContainerSummary, Port}; use crate::{ - app_data::{AppData, ContainerId, ContainerItem, ContainerPorts, State, StatefulList}, + app_data::{ + AppData, ContainerId, ContainerItem, ContainerPorts, Filter, State, StatefulList, + }, parse_args::CliArgs, }; @@ -217,7 +219,7 @@ mod tests { hidden_containers: vec![], error: None, sorted_by: None, - filter_term: None, + filter: Filter::new(), args: gen_args(), } } diff --git a/src/ui/draw_blocks.rs b/src/ui/draw_blocks.rs index 4472f09..9299ab8 100644 --- a/src/ui/draw_blocks.rs +++ b/src/ui/draw_blocks.rs @@ -13,7 +13,7 @@ use ratatui::{ use std::{default::Default, time::Instant}; use std::{fmt::Display, sync::Arc}; -use crate::app_data::{ContainerItem, ContainerName, Header, SortedOrder}; +use crate::app_data::{ContainerItem, ContainerName, FilterBy, Header, SortedOrder}; use crate::{ app_data::{AppData, ByteStats, Columns, CpuStats, State, Stats}, app_error::AppError, @@ -422,17 +422,59 @@ fn make_chart<'a, T: Stats + Display>( ) } +/// Create the filter_by by spans, coloured dependant on which one is selected +fn filter_by_spans(app_data: &Arc>) -> [Span; 4] { + let filter_by = app_data.lock().get_filter_by(); + + let selected = Style::default().bg(Color::Gray).fg(Color::Black); + let not_selected = Style::default().bg(Color::Reset).fg(Color::Reset); + + // This should be refactored somehow + let name = [" Name ", " Image ", " Status ", " All "]; + + match filter_by { + FilterBy::Name => [ + Span::styled(name[0], selected), + Span::styled(name[1], not_selected), + Span::styled(name[2], not_selected), + Span::styled(name[3], not_selected), + ], + FilterBy::Image => [ + Span::styled(name[0], not_selected), + Span::styled(name[1], selected), + Span::styled(name[2], not_selected), + Span::styled(name[3], not_selected), + ], + FilterBy::Status => [ + Span::styled(name[0], not_selected), + Span::styled(name[1], not_selected), + Span::styled(name[2], selected), + Span::styled(name[3], not_selected), + ], + FilterBy::All => [ + Span::styled(name[0], not_selected), + Span::styled(name[1], not_selected), + Span::styled(name[2], not_selected), + Span::styled(name[3], selected), + ], + } +} + /// Draw the filter bar pub fn filter_bar(area: Rect, frame: &mut Frame, app_data: &Arc>) { let style_but = Style::default().fg(Color::Black).bg(Color::Magenta); let style_desc = Style::default().fg(Color::Gray).bg(Color::Reset); - let line = Line::from(vec![ - // Span::styled(" Enter ", style_but), - // Span::styled(" done ", style_desc), + + let mut line = vec![ Span::styled(" Esc ", style_but), Span::styled(" clear ", style_desc), + Span::styled(" ← by → ", style_but), + Span::from(" "), + ]; + line.extend_from_slice(&filter_by_spans(app_data)); + line.extend_from_slice(&[ Span::styled( - "filter: ", + " term: ", Style::default() .fg(Color::Magenta) .add_modifier(Modifier::BOLD), @@ -445,7 +487,7 @@ pub fn filter_bar(area: Rect, frame: &mut Frame, app_data: &Arc>) Style::default().fg(Color::Gray), ), ]); - frame.render_widget(line, area); + frame.render_widget(Line::from(line), area); } /// Draw heading bar at top of program, always visible @@ -579,12 +621,6 @@ pub fn heading_bar( }) .collect::>(); - // // Draw loading icon, or not, and a prefix with a single space - // let loading_paragraph = Paragraph::new(format!("{:>2}", data.loading_icon)) - // .block(block(Color::White)) - // .alignment(Alignment::Center); - // frame.render_widget(loading_paragraph, split_bar[0]); - let container_splits = header_data.iter().map(|i| i.2).collect::>(); let headers_section = Layout::default() .direction(Direction::Horizontal) @@ -998,7 +1034,7 @@ pub fn error(f: &mut Frame, error: AppError, seconds: Option) { } /// Draw info box in one of the 9 BoxLocations -// TODO is this broken? +// TODO is this broken - I don't think so pub fn info(f: &mut Frame, text: &str, instant: Instant, gui_state: &Arc>) { let block = Block::default() .title("") @@ -1148,6 +1184,7 @@ mod tests { .chunks(usize::from(w)) .enumerate() } + // ******************** // // DockerControls panel // // ******************** // @@ -2056,7 +2093,7 @@ mod tests { } } - /// CPU and Memroy charts used in multiple tests, based on data from above insert_chart_data() + /// CPU and Memory charts used in multiple tests, based on data from above insert_chart_data() const EXPECTED: [&str; 10] = [ "╭───────────── cpu 03.00% ─────────────╮╭────────── memory 30.00 kB ───────────╮", "│10.00%│ • ││100.00 kB│ •• │", @@ -2762,7 +2799,9 @@ mod tests { // ********** // #[test] + #[allow(clippy::cognitive_complexity, clippy::too_many_lines)] /// Filter row is drawn correctly & colors are correct + /// Colours change when filter_by option is changed fn test_draw_blocks_filter_row() { let (w, h) = (140, 1); let mut setup = test_setup(w, h, true, true); @@ -2779,7 +2818,7 @@ mod tests { .unwrap(); let expected = [ - " Esc clear filter: " + " Esc clear ← by → Name Image Status All term: " ]; for (row_index, result_row) in get_result(&setup, w) { @@ -2787,7 +2826,7 @@ mod tests { for (result_cell_index, result_cell) in result_row.iter().enumerate() { assert_eq!(result_cell.symbol(), expected_row[result_cell_index]); match result_cell_index { - 0..=4 => { + 0..=4 | 12..=19 => { assert_eq!(result_cell.bg, Color::Magenta); assert_eq!(result_cell.fg, Color::Black); } @@ -2795,9 +2834,14 @@ mod tests { assert_eq!(result_cell.bg, Color::Reset); assert_eq!(result_cell.fg, Color::Gray); } - 12..=19 => { + 21..=26 => { + assert_eq!(result_cell.bg, Color::Gray); + assert_eq!(result_cell.fg, Color::Black); + } + 47..=53 => { assert_eq!(result_cell.bg, Color::Reset); assert_eq!(result_cell.fg, Color::Magenta); + assert_eq!(result_cell.modifier, Modifier::BOLD); } _ => { assert_eq!(result_cell.bg, Color::Reset); @@ -2807,7 +2851,9 @@ mod tests { } } + // Test when char added to search term setup.app_data.lock().filter_term_push('c'); + setup.app_data.lock().filter_term_push('d'); setup .terminal @@ -2817,7 +2863,7 @@ mod tests { .unwrap(); let expected = [ - " Esc clear filter: c " + " Esc clear ← by → Name Image Status All term: cd " ]; for (row_index, result_row) in get_result(&setup, w) { @@ -2826,17 +2872,66 @@ mod tests { assert_eq!(result_cell.symbol(), expected_row[result_cell_index]); match result_cell_index { - 0..=4 => { + 0..=4 | 12..=19 => { assert_eq!(result_cell.bg, Color::Magenta); assert_eq!(result_cell.fg, Color::Black); } - 5..=11 | 20 => { + 5..=11 | 54..=55 => { assert_eq!(result_cell.bg, Color::Reset); assert_eq!(result_cell.fg, Color::Gray); } - 12..=19 => { + 21..=26 => { + assert_eq!(result_cell.bg, Color::Gray); + assert_eq!(result_cell.fg, Color::Black); + } + 47..=53 => { assert_eq!(result_cell.bg, Color::Reset); assert_eq!(result_cell.fg, Color::Magenta); + assert_eq!(result_cell.modifier, Modifier::BOLD); + } + _ => { + assert_eq!(result_cell.bg, Color::Reset); + assert_eq!(result_cell.fg, Color::Reset); + } + } + } + } + + // Test when filter_by chances + setup.app_data.lock().filter_by_next(); + setup + .terminal + .draw(|f| { + super::filter_bar(setup.area, f, &setup.app_data); + }) + .unwrap(); + + let expected = [ + " Esc clear ← by → Name Image Status All term: cd " + ]; + + for (row_index, result_row) in get_result(&setup, w) { + let expected_row = expected_to_vec(&expected, row_index); + for (result_cell_index, result_cell) in result_row.iter().enumerate() { + assert_eq!(result_cell.symbol(), expected_row[result_cell_index]); + + match result_cell_index { + 0..=4 | 12..=19 => { + assert_eq!(result_cell.bg, Color::Magenta); + assert_eq!(result_cell.fg, Color::Black); + } + 5..=11 | 54..=55 => { + assert_eq!(result_cell.bg, Color::Reset); + assert_eq!(result_cell.fg, Color::Gray); + } + 27..=33 => { + assert_eq!(result_cell.bg, Color::Gray); + assert_eq!(result_cell.fg, Color::Black); + } + 47..=53 => { + assert_eq!(result_cell.bg, Color::Reset); + assert_eq!(result_cell.fg, Color::Magenta); + assert_eq!(result_cell.modifier, Modifier::BOLD); } _ => { assert_eq!(result_cell.bg, Color::Reset); @@ -3314,7 +3409,7 @@ mod tests { let expected = [ " name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ", - "╭ Containers 1/1 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮", + "╭ Containers 1/1 - filtered ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮", "│⚪ container_1 ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 image_1 0.00 kB 0.00 kB ││▶ pause │", "│ ││ restart │", "│ ││ stop │", @@ -3342,7 +3437,7 @@ mod tests { "│ │• •• ││ │• •• ││ │", "│ │ ││ │ ││ │", "╰───────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────╯╰────────────────────────────╯", - " Esc clear filter: r_1 " + " Esc clear ← by → Name Image Status All term: r_1 " ]; setup .terminal