diff --git a/README.md b/README.md index e492a62..f59bb97 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ In application controls | ```( enter )```| Run selected docker command.| | ```( 1-9 )``` | Sort containers by heading, clicking on headings also sorts the selected column. | | ```( 0 )``` | Stop sorting.| +| ```( F1 )``` or ```( / )``` | Toggle filter mode. | | ```( e )``` | Exec into the selected container - not available on Windows.| | ```( h )``` | Toggle help menu.| | ```( m )``` | Toggle mouse capture - if disabled, text on screen can be selected.| diff --git a/src/app_data/mod.rs b/src/app_data/mod.rs index d6d8e92..d279384 100644 --- a/src/app_data/mod.rs +++ b/src/app_data/mod.rs @@ -3,6 +3,7 @@ use core::fmt; use parking_lot::Mutex; use ratatui::widgets::{ListItem, ListState}; use std::{ + hash::Hash, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; @@ -60,17 +61,21 @@ impl fmt::Display for Header { pub struct AppData { containers: StatefulList, error: Option, + filter_term: Option, sorted_by: Option<(Header, SortedOrder)>, + hidden_containers: Vec, 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 sorted_by: Option<(Header, SortedOrder)>, - pub args: CliArgs, } impl AppData { @@ -79,8 +84,10 @@ impl AppData { Self { args, containers: StatefulList::new(vec![]), + hidden_containers: vec![], error: None, sorted_by: None, + filter_term: None, } } @@ -93,6 +100,90 @@ impl AppData { .as_secs() } + /// Filter related methods + + /// Get the current filter term + pub const fn get_filter_term(&self) -> Option<&String> { + 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()) + }) + } + + /// 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 + /// 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(); + + if !self.hidden_containers.is_empty() { + let (mut new_items, tmp_items): (Vec<_>, Vec<_>) = self + .hidden_containers + .iter() + .cloned() + .partition(|item| self.can_insert(item.name.get())); + + while let Some(x) = new_items.pop() { + self.containers.items.push(x); + } + self.hidden_containers = tmp_items; + } + + let (new_items, tmp_items) = self + .containers + .items + .iter() + .cloned() + .partition(|item| self.can_insert(item.name.get())); + + self.containers.items = new_items; + self.hidden_containers.extend(tmp_items); + + self.sort_containers(); + if self.get_container_len() != pre_len { + self.containers.start(); + } + } + + /// 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() { + term.push(c); + } else { + 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() { + // 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_containers(); + } + + /// Remove the filter term completely, empty the "hidden" container vec + pub fn filter_term_clear(&mut self) { + 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); + }; + } + self.sort_containers(); + } + /// Container sort related methods /// Change the sorted order, also set the selected container state to match new order @@ -206,7 +297,7 @@ impl AppData { /// Container state methods - /// Just get the total number of containers + /// Get the total number of none "hidden" containers pub fn get_container_len(&self) -> usize { self.containers.items.len() } @@ -260,35 +351,35 @@ impl AppData { let mut longest_private = 10; let mut longest_public = 9; - for item in &self.containers.items { - // if let Some(ports) = item.ports.as_ref() { - longest_ip = longest_ip.max( - item.ports - .iter() - .map(ContainerPorts::len_ip) - .max() - .unwrap_or(3), - ); - longest_private = longest_private.max( - item.ports - .iter() - .map(ContainerPorts::len_private) - .max() - .unwrap_or(8), - ); - longest_public = longest_public.max( - item.ports - .iter() - .map(ContainerPorts::len_public) - .max() - .unwrap_or(6), - ); + for item in [&self.containers.items, &self.hidden_containers] { + for item in item { + longest_ip = longest_ip.max( + item.ports + .iter() + .map(ContainerPorts::len_ip) + .max() + .unwrap_or(3), + ); + longest_private = longest_private.max( + item.ports + .iter() + .map(ContainerPorts::len_private) + .max() + .unwrap_or(8), + ); + longest_public = longest_public.max( + item.ports + .iter() + .map(ContainerPorts::len_public) + .max() + .unwrap_or(6), + ); + } } - // } (longest_ip, longest_private, longest_public) - // ) } + /// Get Option of the current selected container's ports, sorted by private port pub fn get_selected_ports(&mut self) -> Option<(Vec, State)> { if let Some(item) = self.get_mut_selected_container() { @@ -307,11 +398,16 @@ impl AppData { .and_then(|i| self.containers.items.get_mut(i)) } - /// return a mutable container by given id + /// Get a mutable container by given id fn get_container_by_id(&mut self, id: &ContainerId) -> Option<&mut ContainerItem> { self.containers.items.iter_mut().find(|i| &i.id == id) } + /// Get a mutable container by given id in the tmp_container vec + fn get_hidden_container_by_id(&mut self, id: &ContainerId) -> Option<&mut ContainerItem> { + self.hidden_containers.iter_mut().find(|i| &i.id == id) + } + /// Get the ContainerName of by ID pub fn get_container_name_by_id(&mut self, id: &ContainerId) -> Option { self.containers @@ -333,6 +429,7 @@ impl AppData { self.get_selected_container() .map(|i| (i.id.clone(), i.state, i.name.get().to_owned())) } + /// Selected DockerCommand methods /// Get the current selected docker command @@ -467,17 +564,17 @@ impl AppData { /// Error related methods - /// return single app_state error + /// Get single app_state error pub const fn get_error(&self) -> Option { self.error } - /// remove single app_state error + /// Remove single app_state error pub fn remove_error(&mut self) { self.error = None; } - /// insert single app_state error + /// Insert single app_state error pub fn set_error(&mut self, error: AppError, gui_state: &Arc>, status: Status) { gui_state.lock().status_push(status); self.error = Some(error); @@ -498,44 +595,55 @@ impl AppData { /// Find the widths for the strings in the containers panel. /// So can display nicely and evenly + /// Searches in both containes & 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); // Should probably find a refactor here somewhere - for container in &self.containers.items { - let cpu_count = count( - &container - .cpu_stats - .back() - .unwrap_or(&CpuStats::default()) - .to_string(), - ); + for container in [&self.containers.items, &self.hidden_containers] { + for container in container { + let cpu_count = count( + &container + .cpu_stats + .back() + .unwrap_or(&CpuStats::default()) + .to_string(), + ); - let mem_current_count = count( - &container - .mem_stats - .back() - .unwrap_or(&ByteStats::default()) - .to_string(), - ); + let mem_current_count = count( + &container + .mem_stats + .back() + .unwrap_or(&ByteStats::default()) + .to_string(), + ); - // Issue here! - columns.cpu.1 = columns.cpu.1.max(cpu_count); - columns.image.1 = columns.image.1.max(count(&container.image.to_string())); - columns.mem.1 = columns.mem.1.max(mem_current_count); - columns.mem.2 = columns.mem.2.max(count(&container.mem_limit.to_string())); - columns.name.1 = columns.name.1.max(count(&container.name.to_string())); - columns.net_rx.1 = columns.net_rx.1.max(count(&container.rx.to_string())); - columns.net_tx.1 = columns.net_tx.1.max(count(&container.tx.to_string())); - columns.state.1 = columns.state.1.max(count(&container.state.to_string())); - columns.status.1 = columns.status.1.max(count(&container.status)); + columns.cpu.1 = columns.cpu.1.max(cpu_count); + columns.image.1 = columns.image.1.max(count(&container.image.to_string())); + columns.mem.1 = columns.mem.1.max(mem_current_count); + columns.mem.2 = columns.mem.2.max(count(&container.mem_limit.to_string())); + columns.name.1 = columns.name.1.max(count(&container.name.to_string())); + columns.net_rx.1 = columns.net_rx.1.max(count(&container.rx.to_string())); + columns.net_tx.1 = columns.net_tx.1.max(count(&container.tx.to_string())); + columns.state.1 = columns.state.1.max(count(&container.state.to_string())); + columns.status.1 = columns.status.1.max(count(&container.status)); + } } columns } /// Update related methods + /// Get mutable reference to a container in the containers vec & the hidden_containers vec + fn get_any_container_by_id(&mut self, id: &ContainerId) -> Option<&mut ContainerItem> { + if self.get_hidden_container_by_id(id).is_some() { + self.get_hidden_container_by_id(id) + } else { + self.get_container_by_id(id) + } + } + /// Update container mem, cpu, & network stats, in single function so only need to call .lock() once /// Will also, if a sort is set, sort the containers pub fn update_stats_by_id( @@ -547,7 +655,7 @@ impl AppData { rx: u64, tx: u64, ) { - if let Some(container) = self.get_container_by_id(id) { + if let Some(container) = self.get_any_container_by_id(id) { if container.cpu_stats.len() >= 60 { container.cpu_stats.pop_front(); } @@ -642,8 +750,8 @@ impl AppData { let created = i .created .map_or(0, |i| u64::try_from(i).unwrap_or_default()); - // If container info already in containers Vec, then just update details - if let Some(item) = self.get_container_by_id(&id) { + + if let Some(item) = self.get_any_container_by_id(&id) { if item.name.get() != name { item.name.set(name); }; @@ -668,24 +776,29 @@ impl AppData { item.image.set(image); }; } else { - // container not known, so make new ContainerItem and push into containers Vec + // 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, ); - self.containers.items.push(container); + if can_insert { + self.containers.items.push(container); + } else { + self.hidden_containers.push(container); + } } } } } - /// update logs of a given container, based on id + /// Update logs of a given container, based on id pub fn update_log_by_id(&mut self, logs: Vec, id: &ContainerId) { let color = self.args.color; let raw = self.args.raw; let timestamp = self.args.timestamp; - if let Some(container) = self.get_container_by_id(id) { + if let Some(container) = self.get_any_container_by_id(id) { if !container.is_oxker { container.last_updated = Self::get_systemtime(); let current_len = container.logs.len(); @@ -1433,6 +1546,36 @@ mod tests { test_state(State::Unknown, &mut vec![DockerControls::Delete]); } + // ****** // + // Filter // + // ****** // + + #[test] + /// Data is filtered correctly + fn test_app_data_filter() { + 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(); + app_data.filter_term_push('_'); + app_data.filter_term_push('2'); + + assert_eq!(app_data.get_filter_term(), Some(&"_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); + + // 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")); + } + // **** // // Logs // // **** // @@ -1732,6 +1875,32 @@ mod tests { assert_eq!(result, expected); } + #[test] + /// Header widths return correctly when some containers hidden + fn test_app_data_get_width_filtered() { + let (_ids, mut containers) = gen_containers(); + containers[0].name = ContainerName::from("some_longer_name_with_filter"); + let mut app_data = gen_appdata(&containers); + + let result = app_data.get_width(); + let expected = Columns { + name: (Header::Name, 28), + state: (Header::State, 11), + status: (Header::Status, 16), + cpu: (Header::Cpu, 7), + mem: (Header::Memory, 7, 7), + id: (Header::Id, 8), + image: (Header::Image, 7), + net_rx: (Header::Rx, 7), + net_tx: (Header::Tx, 7), + }; + + assert_eq!(result, expected); + app_data.filter_term_push('c'); + app_data.filter_containers(); + assert_eq!(result, expected); + } + // ***** // // Ports // // ***** // diff --git a/src/docker_data/mod.rs b/src/docker_data/mod.rs index 302f0d5..8508a28 100644 --- a/src/docker_data/mod.rs +++ b/src/docker_data/mod.rs @@ -303,13 +303,14 @@ impl DockerData { }; self.update_all_container_stats(&all_ids); self.app_data.lock().sort_containers(); + self.gui_state.lock().stop_loading_animation(Uuid::nil()); } /// Initialize docker container data, before any messages are received async fn initialise_container_data(&mut self) { self.gui_state.lock().status_push(Status::Init); let loading_uuid = Uuid::new_v4(); - let loading_handle = GuiState::start_loading_animation(&self.gui_state, loading_uuid); + GuiState::start_loading_animation(&self.gui_state, loading_uuid); let all_ids = self.update_all_containers().await; self.update_all_container_stats(&all_ids); @@ -323,9 +324,7 @@ impl DockerData { self.init = None; } } - self.gui_state - .lock() - .stop_loading_animation(&loading_handle, loading_uuid); + self.gui_state.lock().stop_loading_animation(loading_uuid); self.gui_state.lock().status_del(Status::Init); } @@ -356,27 +355,27 @@ impl DockerData { } DockerMessage::Pause(id) => { tokio::spawn(async move { - let handle = GuiState::start_loading_animation(&gui_state, uuid); + GuiState::start_loading_animation(&gui_state, uuid); if docker.pause_container(id.get()).await.is_err() { Self::set_error(&app_data, DockerControls::Pause, &gui_state); } - gui_state.lock().stop_loading_animation(&handle, uuid); + gui_state.lock().stop_loading_animation(uuid); }); self.update_everything().await; } DockerMessage::Restart(id) => { tokio::spawn(async move { - let handle = GuiState::start_loading_animation(&gui_state, uuid); + GuiState::start_loading_animation(&gui_state, uuid); if docker.restart_container(id.get(), None).await.is_err() { Self::set_error(&app_data, DockerControls::Restart, &gui_state); } - gui_state.lock().stop_loading_animation(&handle, uuid); + gui_state.lock().stop_loading_animation(uuid); }); self.update_everything().await; } DockerMessage::Start(id) => { tokio::spawn(async move { - let handle = GuiState::start_loading_animation(&gui_state, uuid); + GuiState::start_loading_animation(&gui_state, uuid); if docker .start_container(id.get(), None::>) .await @@ -384,33 +383,33 @@ impl DockerData { { Self::set_error(&app_data, DockerControls::Start, &gui_state); } - gui_state.lock().stop_loading_animation(&handle, uuid); + gui_state.lock().stop_loading_animation(uuid); }); self.update_everything().await; } DockerMessage::Stop(id) => { tokio::spawn(async move { - let handle = GuiState::start_loading_animation(&gui_state, uuid); + GuiState::start_loading_animation(&gui_state, uuid); if docker.stop_container(id.get(), None).await.is_err() { Self::set_error(&app_data, DockerControls::Stop, &gui_state); } - gui_state.lock().stop_loading_animation(&handle, uuid); + gui_state.lock().stop_loading_animation(uuid); }); self.update_everything().await; } DockerMessage::Resume(id) => { tokio::spawn(async move { - let handle = GuiState::start_loading_animation(&gui_state, uuid); + GuiState::start_loading_animation(&gui_state, uuid); if docker.unpause_container(id.get()).await.is_err() { Self::set_error(&app_data, DockerControls::Resume, &gui_state); } - gui_state.lock().stop_loading_animation(&handle, uuid); + gui_state.lock().stop_loading_animation(uuid); }); self.update_everything().await; } DockerMessage::Delete(id) => { tokio::spawn(async move { - let handle = GuiState::start_loading_animation(&gui_state, uuid); + GuiState::start_loading_animation(&gui_state, uuid); if docker .remove_container( id.get(), @@ -425,7 +424,7 @@ impl DockerData { { Self::set_error(&app_data, DockerControls::Stop, &gui_state); } - gui_state.lock().stop_loading_animation(&handle, uuid); + gui_state.lock().stop_loading_animation(uuid); }); self.update_everything().await; self.gui_state.lock().set_delete_container(None); diff --git a/src/input_handler/mod.rs b/src/input_handler/mod.rs index 2cb4f8e..8df7342 100644 --- a/src/input_handler/mod.rs +++ b/src/input_handler/mod.rs @@ -71,6 +71,7 @@ impl InputHandler { Status::Error, Status::Help, Status::DeleteConfirm, + Status::Filter, ]) { self.mouse_press(mouse_event); } @@ -125,7 +126,7 @@ impl InputHandler { let is_oxker = self.app_data.lock().is_oxker(); if !is_oxker && tty_readable() { let uuid = Uuid::new_v4(); - let handle = GuiState::start_loading_animation(&self.gui_state, uuid); + GuiState::start_loading_animation(&self.gui_state, uuid); let (sx, rx) = tokio::sync::oneshot::channel::>(); self.docker_tx.send(DockerMessage::Exec(sx)).await.ok(); @@ -143,7 +144,7 @@ impl InputHandler { }, ); } - self.gui_state.lock().stop_loading_animation(&handle, uuid); + self.gui_state.lock().stop_loading_animation(uuid); } } @@ -248,7 +249,7 @@ impl InputHandler { self.gui_state.lock().status_push(log_status); let uuid = Uuid::new_v4(); - let handle = GuiState::start_loading_animation(&self.gui_state, uuid); + GuiState::start_loading_animation(&self.gui_state, uuid); if save_logs(&self.app_data, &self.gui_state, &self.docker_tx) .await .is_err() @@ -260,7 +261,7 @@ impl InputHandler { ); } self.gui_state.lock().status_del(log_status); - self.gui_state.lock().stop_loading_animation(&handle, uuid); + self.gui_state.lock().stop_loading_animation(uuid); } } @@ -353,6 +354,98 @@ impl InputHandler { } } + /// Actions to take when in Help status active + fn handle_help(&mut self, key_code: KeyCode) { + match key_code { + KeyCode::Esc | KeyCode::Char('h' | 'H') => { + self.gui_state.lock().status_del(Status::Help); + } + KeyCode::Char('m' | 'M') => self.m_key(), + _ => (), + } + } + + /// Actions to take when Error status active + fn handle_error(&mut self, key_code: KeyCode) { + match key_code { + KeyCode::Esc | KeyCode::Char('c' | 'C') => { + self.app_data.lock().remove_error(); + self.gui_state.lock().status_del(Status::Error); + } + _ => (), + } + } + + /// Actions to take when Delete status active + async fn handle_delete(&mut self, key_code: KeyCode) { + match key_code { + KeyCode::Char('y' | 'Y') => self.confirm_delete().await, + KeyCode::Esc | KeyCode::Char('n' | 'N') => self.clear_delete(), + _ => (), + } + } + + /// Actions to take when Filter status active + fn handle_filter(&mut self, key_code: KeyCode) { + match key_code { + KeyCode::F(1) | KeyCode::Char('/') | KeyCode::Esc => { + self.app_data.lock().filter_term_clear(); + self.gui_state.lock().status_del(Status::Filter); + } + KeyCode::Enter => { + self.gui_state.lock().status_del(Status::Filter); + } + KeyCode::Backspace => { + self.app_data.lock().filter_term_pop(); + } + KeyCode::Char(x) => { + self.app_data.lock().filter_term_push(x); + } + _ => (), + } + } + + /// Handle button presses in all other scenarios + async fn handle_others(&mut self, key_code: KeyCode) { + match key_code { + KeyCode::Char('0') => self.app_data.lock().reset_sorted(), + KeyCode::Char('1') => self.sort(Header::Name), + KeyCode::Char('2') => self.sort(Header::State), + KeyCode::Char('3') => self.sort(Header::Status), + KeyCode::Char('4') => self.sort(Header::Cpu), + KeyCode::Char('5') => self.sort(Header::Memory), + KeyCode::Char('6') => self.sort(Header::Id), + KeyCode::Char('7') => self.sort(Header::Image), + KeyCode::Char('8') => self.sort(Header::Rx), + KeyCode::Char('9') => self.sort(Header::Tx), + KeyCode::Char('e' | 'E') => self.e_key().await, + KeyCode::Char('h' | 'H') => self.gui_state.lock().status_push(Status::Help), + KeyCode::Char('m' | 'M') => self.m_key(), + KeyCode::Char('s' | 'S') => self.s_key().await, + KeyCode::Tab => self.tab_key(), + KeyCode::BackTab => self.back_tab_key(), + KeyCode::Home => self.home_key(), + KeyCode::End => self.end_key(), + KeyCode::Up | KeyCode::Char('k' | 'K') => self.previous(), + KeyCode::PageUp => { + for _ in 0..=6 { + self.previous(); + } + } + KeyCode::F(1) | KeyCode::Char('/') => { + self.gui_state.lock().status_push(Status::Filter); + self.docker_tx.send(DockerMessage::Update).await.ok(); + } + KeyCode::Down | KeyCode::Char('j' | 'J') => self.next(), + KeyCode::PageDown => { + for _ in 0..=6 { + self.next(); + } + } + KeyCode::Enter => self.enter_key().await, + _ => (), + } + } /// Handle keyboard button events async fn button_press(&mut self, key_code: KeyCode, key_modifier: KeyModifiers) { let contains_delete = self @@ -365,72 +458,26 @@ impl InputHandler { let contains_error = contains(Status::Error); let contains_help = contains(Status::Help); let contains_exec = contains(Status::Exec); + let contains_filter: bool = contains(Status::Filter); if !contains_exec { - // Always just quit on Ctrl + c/C or q/Q let is_c = || key_code == KeyCode::Char('c') || key_code == KeyCode::Char('C'); let is_q = || key_code == KeyCode::Char('q') || key_code == KeyCode::Char('Q'); - if key_modifier == KeyModifiers::CONTROL && is_c() || is_q() { + if key_modifier == KeyModifiers::CONTROL && is_c() || is_q() && !contains_filter { + // Always just quit on Ctrl + c/C or q/Q, unless in FIlter status active self.quit().await; } if contains_error { - match key_code { - KeyCode::Esc | KeyCode::Char('c' | 'C') => { - self.app_data.lock().remove_error(); - self.gui_state.lock().status_del(Status::Error); - } - _ => (), - } + self.handle_error(key_code); } else if contains_help { - match key_code { - KeyCode::Esc | KeyCode::Char('h' | 'H') => { - self.gui_state.lock().status_del(Status::Help); - } - KeyCode::Char('m' | 'M') => self.m_key(), - _ => (), - } + self.handle_help(key_code); + } else if contains_filter { + self.handle_filter(key_code); } else if contains_delete { - match key_code { - KeyCode::Char('y' | 'Y') => self.confirm_delete().await, - KeyCode::Esc | KeyCode::Char('n' | 'N') => self.clear_delete(), - _ => (), - } + self.handle_delete(key_code).await; } else { - match key_code { - KeyCode::Char('0') => self.app_data.lock().reset_sorted(), - KeyCode::Char('1') => self.sort(Header::Name), - KeyCode::Char('2') => self.sort(Header::State), - KeyCode::Char('3') => self.sort(Header::Status), - KeyCode::Char('4') => self.sort(Header::Cpu), - KeyCode::Char('5') => self.sort(Header::Memory), - KeyCode::Char('6') => self.sort(Header::Id), - KeyCode::Char('7') => self.sort(Header::Image), - KeyCode::Char('8') => self.sort(Header::Rx), - KeyCode::Char('9') => self.sort(Header::Tx), - KeyCode::Char('e' | 'E') => self.e_key().await, - KeyCode::Char('h' | 'H') => self.gui_state.lock().status_push(Status::Help), - KeyCode::Char('m' | 'M') => self.m_key(), - KeyCode::Char('s' | 'S') => self.s_key().await, - KeyCode::Tab => self.tab_key(), - KeyCode::BackTab => self.back_tab_key(), - KeyCode::Home => self.home_key(), - KeyCode::End => self.end_key(), - KeyCode::Up | KeyCode::Char('k' | 'K') => self.previous(), - KeyCode::PageUp => { - for _ in 0..=6 { - self.previous(); - } - } - KeyCode::Down | KeyCode::Char('j' | 'J') => self.next(), - KeyCode::PageDown => { - for _ in 0..=6 { - self.next(); - } - } - KeyCode::Enter => self.enter_key().await, - _ => (), - } + self.handle_others(key_code).await; } } } diff --git a/src/main.rs b/src/main.rs index f01e318..9ef3fee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -167,6 +167,11 @@ async fn main() { #[cfg(test)] #[allow(clippy::unwrap_used, clippy::many_single_char_names, unused)] mod tests { + use std::{ + collections::{HashSet, VecDeque}, + vec, + }; + use bollard::service::{ContainerSummary, Port}; use crate::{ @@ -209,8 +214,10 @@ mod tests { pub fn gen_appdata(containers: &[ContainerItem]) -> AppData { AppData { containers: StatefulList::new(containers.to_vec()), + hidden_containers: vec![], error: None, sorted_by: None, + filter_term: None, args: gen_args(), } } diff --git a/src/ui/draw_blocks.rs b/src/ui/draw_blocks.rs index f2d0b60..294463c 100644 --- a/src/ui/draw_blocks.rs +++ b/src/ui/draw_blocks.rs @@ -21,7 +21,7 @@ use crate::{ use super::{ gui_state::{BoxLocation, DeleteButton, Region}, - FrameData, + FrameData, Status, }; use super::{GuiState, SelectablePanel}; @@ -98,7 +98,7 @@ fn generate_block<'a>( .borders(Borders::ALL) .border_type(BorderType::Rounded) .title(title); - if fd.selected_panel == panel { + if fd.selected_panel == panel && !gui_state.lock().status_contains(&[Status::Filter]) { block = block.border_style(Style::default().fg(Color::LightCyan)); } block @@ -233,7 +233,15 @@ pub fn containers( .collect::>(); if items.is_empty() { - let paragraph = Paragraph::new("no containers running") + let text = if app_data.lock().get_filter_term().is_some() { + "no containers match filter" + } else if gui_state.lock().is_loading() { + &format!("loading {}", fd.loading_icon) + } else { + "no containers running" + }; + + let paragraph = Paragraph::new(text) .block(block) .alignment(Alignment::Center); f.render_widget(paragraph, area); @@ -414,6 +422,32 @@ fn make_chart<'a, T: Stats + Display>( ) } +/// 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), + Span::styled(" Esc ", style_but), + Span::styled(" clear ", style_desc), + Span::styled( + "filter: ", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + app_data + .lock() + .get_filter_term() + .map_or(String::new(), std::borrow::ToOwned::to_owned), + Style::default().fg(Color::Gray), + ), + ]); + frame.render_widget(line, area); +} + /// Draw heading bar at top of program, always visible /// TODO Should separate into loading icon/headers/help functions #[allow(clippy::too_many_lines)] @@ -521,6 +555,11 @@ pub fn heading_bar( .constraints(splits) .split(area); + // 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::Left); + frame.render_widget(loading_paragraph, split_bar[0]); if data.has_containers { let header_section_width = split_bar[1].width; @@ -540,11 +579,11 @@ 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]); + // // 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() @@ -701,6 +740,13 @@ impl HelpInfo { "toggle mouse capture - if disabled, text on screen can be selected & copied", ), ]), + Line::from(vec![ + space(), + button_item("F1"), + or(), + button_item("/"), + button_desc("toggle filter mode"), + ]), Line::from(vec![space(), button_item("0"), button_desc("stop sort")]), Line::from(vec![ space(), @@ -2461,7 +2507,7 @@ mod tests { /// This will cause issues once the version has more than the current 5 chars (0.5.0) // Help popup is drawn correctly fn test_draw_blocks_help() { - let (w, h) = (87, 32); + let (w, h) = (87, 33); let mut setup = test_setup(w, h, true, true); setup @@ -2492,6 +2538,7 @@ mod tests { " │ ( h ) toggle this help information │ ".to_owned(), " │ ( s ) save logs to file │ ".to_owned(), " │ ( m ) toggle mouse capture - if disabled, text on screen can be selected & copied │ ".to_owned(), + " │ ( F1 ) or ( / ) toggle filter mode │ ".to_owned(), " │ ( 0 ) stop sort │ ".to_owned(), " │ ( 1 - 9 ) sort by header - or click header │ ".to_owned(), " │ ( esc ) close dialog │ ".to_owned(), @@ -2502,6 +2549,7 @@ mod tests { " │ │ ".to_owned(), " │ │ ".to_owned(), " ╰───────────────────────────────────────────────────────────────────────────────────╯ ".to_owned(), + " ".to_owned(), ]; for (row_index, row) in expected.iter().enumerate() { @@ -3099,7 +3147,7 @@ mod tests { }); let expected = [ - " name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ", + " name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ", "╭ Containers 1/3 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮", "│⚪ container_1 ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 image_1 0.00 kB 0.00 kB ││▶ pause │", "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB ││ restart │", @@ -3148,6 +3196,134 @@ mod tests { } } + #[test] + #[allow(clippy::too_many_lines)] + /// Check that the whole layout is drawn correctly + fn test_draw_blocks_whole_layout_with_filter() { + let (w, h) = (160, 30); + let mut setup = test_setup(w, h, true, true); + insert_chart_data(&setup); + insert_logs(&setup); + + setup.app_data.lock().containers.items[1] + .ports + .push(ContainerPorts { + ip: Some("127.0.0.1".to_owned()), + private: 8003, + public: Some(8003), + }); + + let expected = [ + " name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ", + "╭ Containers 1/3 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮", + "│⚪ container_1 ✓ running Up 1 hour 03.00% 30.00 kB / 30.00 kB 1 image_1 0.00 kB 0.00 kB ││▶ pause │", + "│ container_2 ✓ running Up 2 hour 00.00% 0.00 kB / 0.00 kB 2 image_2 0.00 kB 0.00 kB ││ restart │", + "│ container_3 ✓ running Up 3 hour 00.00% 0.00 kB / 0.00 kB 3 image_3 0.00 kB 0.00 kB ││ stop │", + "│ ││ delete │", + "│ ││ │", + "│ ││ │", + "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────╯", + "╭ Logs 3/3 - container_1 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│ line 1 │", + "│ line 2 │", + "│▶ line 3 │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + "╭───────────────────────── cpu 03.00% ──────────────────────────╮╭─────────────────────── memory 30.00 kB ───────────────────────╮╭────────── ports ───────────╮", + "│10.00%│ •••• ││100.00 kB│ ••• ││ ip private public│", + "│ │ ••• • ││ │ ••• • ││ 8001 │", + "│ │•• ••• ││ │•• ••• ││ │", + "│ │ ││ │ ││ │", + "╰───────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────╯╰────────────────────────────╯", + ]; + setup + .terminal + .draw(|f| { + draw_frame(f, &setup.app_data, &setup.gui_state); + }) + .unwrap(); + + let result = &setup.terminal.backend().buffer().content; + + for (row_index, row) in result.chunks(usize::from(w)).enumerate() { + let expected_row = expected[row_index] + .chars() + .map(|i| i.to_string()) + .collect::>(); + for (cell_index, cell) in row.iter().enumerate() { + assert_eq!(cell.symbol(), expected_row[cell_index]); + } + } + + setup + .gui_state + .lock() + .status_push(crate::ui::Status::Filter); + setup.app_data.lock().filter_term_push('r'); + setup.app_data.lock().filter_term_push('_'); + setup.app_data.lock().filter_term_push('1'); + + let expected = [ + " name state status cpu memory/limit id image ↓ rx ↑ tx ( h ) show help ", + "╭ Containers 1/1 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭──────────────╮", + "│⚪ 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 │", + "│ ││ delete │", + "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────╯", + "╭ Logs 3/3 - container_1 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮", + "│ line 1 │", + "│ line 2 │", + "│▶ line 3 │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "│ │", + "╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯", + "╭────────────────────────── cpu 03.00% ───────────────────────────╮╭──────────────────────── memory 30.00 kB ────────────────────────╮╭──────── ports ─────────╮", + "│10.00%│ ••• ││100.00 kB│ ••• ││ ip private public│", + "│ │ •• • ││ │ •• • ││ 8001 │", + "│ │ ••• • • ││ │ ••• •• ││ │", + "│ │• •• ││ │• • ││ │", + "│ │ ││ │ ││ │", + "╰─────────────────────────────────────────────────────────────────╯╰─────────────────────────────────────────────────────────────────╯╰────────────────────────╯", + " Enter done Esc clear filter: r_1 ", + ]; + setup + .terminal + .draw(|f| { + draw_frame(f, &setup.app_data, &setup.gui_state); + }) + .unwrap(); + + let result = &setup.terminal.backend().buffer().content; + + for (row_index, row) in result.chunks(usize::from(w)).enumerate() { + let expected_row = expected[row_index] + .chars() + .map(|i| i.to_string()) + .collect::>(); + for (cell_index, cell) in row.iter().enumerate() { + assert_eq!(cell.symbol(), expected_row[cell_index]); + } + } + } + #[test] /// Check that the whole layout is drawn correctly when have long container name and long image name fn test_draw_blocks_whole_layout_long_name() { diff --git a/src/ui/gui_state.rs b/src/ui/gui_state.rs index d93b1dc..354343a 100644 --- a/src/ui/gui_state.rs +++ b/src/ui/gui_state.rs @@ -163,19 +163,21 @@ pub enum Status { DockerConnect, Error, Exec, + Filter, Help, Init, Logs, } /// Global gui_state, stored in an Arc -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default)] pub struct GuiState { delete_container: Option, delete_map: HashMap, heading_map: HashMap, - is_loading: HashSet, + loading_handle: Option>, loading_index: u8, + loading_set: HashSet, panel_map: HashMap, selected_panel: SelectablePanel, status: HashSet, @@ -325,45 +327,46 @@ impl GuiState { } else { self.loading_index += 1; } - self.is_loading.insert(uuid); + self.loading_set.insert(uuid); } + pub fn is_loading(&self) -> bool { + !self.loading_set.is_empty() + } /// If is_loading has any entries, return the char at FRAMES[index], else an empty char, which needs to take up the same space, hence ' ' pub fn get_loading(&self) -> char { - if self.is_loading.is_empty() { - ' ' - } else { + if self.is_loading() { FRAMES[usize::from(self.loading_index)] - } - } - - /// Remove a loading_uuid from the is_loading HashSet, if empty, reset loading_index to 0 - fn remove_loading(&mut self, uuid: Uuid) { - self.is_loading.remove(&uuid); - if self.is_loading.is_empty() { - self.loading_index = 0; + } else { + ' ' } } /// Animate the loading icon in its own Tokio thread - pub fn start_loading_animation( - gui_state: &Arc>, - loading_uuid: Uuid, - ) -> JoinHandle<()> { + /// This should only be able to executed once, rather than multiple spawns + pub fn start_loading_animation(gui_state: &Arc>, loading_uuid: Uuid) { + if !gui_state.lock().is_loading() { + let inner_state = Arc::clone(gui_state); + gui_state.lock().loading_handle = Some(tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + inner_state.lock().next_loading(loading_uuid); + } + })); + } gui_state.lock().next_loading(loading_uuid); - let gui_state = Arc::clone(gui_state); - tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - gui_state.lock().next_loading(loading_uuid); - } - }) } /// Stop the loading_spin function, and reset gui loading status - pub fn stop_loading_animation(&mut self, handle: &JoinHandle<()>, loading_uuid: Uuid) { - handle.abort(); - self.remove_loading(loading_uuid); + pub fn stop_loading_animation(&mut self, loading_uuid: Uuid) { + self.loading_set.remove(&loading_uuid); + if self.loading_set.is_empty() { + self.loading_index = 0; + if let Some(h) = &self.loading_handle { + h.abort(); + } + self.loading_handle = None; + } } /// Set info box content diff --git a/src/ui/mod.rs b/src/ui/mod.rs index c970bfc..9fca730 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -64,7 +64,6 @@ impl Ui { is_running: Arc, ) { if let Ok(mut terminal) = Self::setup_terminal() { - // let args = app_data.lock().args.clone(); let cursor_position = terminal.get_cursor().unwrap_or_default(); let mut ui = Self { app_data, @@ -264,14 +263,20 @@ impl From<(MutexGuard<'_, AppData>, MutexGuard<'_, GuiState>)> for FrameData { /// Draw the main ui to a frame of the terminal fn draw_frame(f: &mut Frame, app_data: &Arc>, gui_state: &Arc>) { let fd = FrameData::from((app_data.lock(), gui_state.lock())); + let contains_filter = gui_state.lock().status_contains(&[Status::Filter]); + + let whole_constraints = if contains_filter { + vec![Constraint::Max(1), Constraint::Min(1), Constraint::Max(1)] + } else { + vec![Constraint::Max(1), Constraint::Min(1)] + }; let whole_layout = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Max(1), Constraint::Min(1)].as_ref()) + .constraints(whole_constraints) .split(f.size()); // Split into 3, containers+controls, logs, then graphs - // This one is the issue! let upper_main = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Max(fd.height), Constraint::Min(1)].as_ref()) @@ -306,6 +311,11 @@ fn draw_frame(f: &mut Frame, app_data: &Arc>, gui_state: &Arc>, gui_state: &Arc