feat: log search functionality, closes #72

This commit is contained in:
Jack Wills
2025-09-20 22:04:40 +00:00
parent 03599b4665
commit 96d9469623
33 changed files with 1278 additions and 243 deletions
+330 -15
View File
@@ -22,6 +22,12 @@ const ONE_KB: f64 = 1000.0;
const ONE_MB: f64 = ONE_KB * 1000.0;
const ONE_GB: f64 = ONE_MB * 1000.0;
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
pub enum ScrollDirection {
Next,
Previous,
}
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
pub struct ContainerId(String);
@@ -177,7 +183,14 @@ impl<T> StatefulList<T> {
self.state.select(Some(0));
}
pub fn next(&mut self) {
pub fn scroll(&mut self, scroll: &ScrollDirection) {
match scroll {
ScrollDirection::Next => self.next(),
ScrollDirection::Previous => self.previous(),
}
}
fn next(&mut self) {
if !self.items.is_empty() {
self.state.select(Some(
self.state.selected().map_or(
@@ -190,7 +203,7 @@ impl<T> StatefulList<T> {
}
}
pub fn previous(&mut self) {
fn previous(&mut self) {
if !self.items.is_empty() {
self.state.select(Some(
self.state
@@ -600,6 +613,8 @@ impl LogsTz {
pub struct Logs {
lines: StatefulList<Text<'static>>,
tz: HashSet<LogsTz>,
search_results: Vec<usize>,
search_term: Option<String>,
offset: u16,
max_log_len: usize,
adjusted_max_width: usize,
@@ -614,6 +629,8 @@ impl Default for Logs {
lines,
tz: HashSet::new(),
offset: 0,
search_term: None,
search_results: vec![],
adjusted_max_width: 0,
adjust_max_width_text_len: 0,
max_log_len: 0,
@@ -621,12 +638,189 @@ impl Default for Logs {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash)]
pub enum LogsButton {
Both,
Next,
Previous,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash)]
pub struct LogSearch {
pub term: Option<String>,
pub result: Option<String>,
pub buttons: Option<LogsButton>,
}
/// LogSearch is used in FrameData
impl From<&Logs> for LogSearch {
fn from(l: &Logs) -> Self {
let buttons = l.lines.state.selected().as_ref().and_then(|x| {
let show_next = l.search_results.iter().any(|n| n > x);
let show_previous = l.search_results.iter().any(|n| n < x);
match (show_next, show_previous) {
(true, true) => Some(LogsButton::Both),
(true, false) => Some(LogsButton::Next),
(false, true) => Some(LogsButton::Previous),
(false, false) => None,
}
});
Self {
term: l.search_term.clone(),
result: l.get_search_result(),
buttons,
}
}
}
impl Logs {
pub fn gen_log_search(&self) -> LogSearch {
LogSearch::from(self)
}
/// Scroll to the next or previous search result, accounts for when currently selected line isn't in the results vec
pub fn search_scroll(&mut self, sd: &ScrollDirection) -> Option<()> {
if let Some(current_selected) = self.lines.state.selected() {
if let Some(current_position) = self
.search_results
.iter()
.position(|i| i == &current_selected)
{
if let Some(new_index) = match sd {
ScrollDirection::Next => current_position.checked_add(1),
ScrollDirection::Previous => current_position.checked_sub(1),
} {
if let Some(f) = self.search_results.get(new_index) {
self.lines.state.select(Some(*f));
return Some(());
}
}
} else {
let range = match sd {
ScrollDirection::Previous => (0..=current_selected).rev().collect::<Vec<_>>(),
ScrollDirection::Next => (current_selected
..=self
.search_results
.last()
.map_or_else(|| current_selected, |i| *i))
.collect::<Vec<_>>(),
};
for i in range {
if self.search_results.contains(&i) {
self.lines.state.select(Some(i));
return Some(());
}
}
}
}
None
}
/// Get a string x/y, where y is total matches found, and x is current ordered selected line
/// WIll be padded by max chars of total matches
fn get_search_result(&self) -> Option<String> {
if self.search_results.is_empty() {
return None;
}
Some(self.lines.state.selected().map_or_else(
|| format!("{}", self.search_results.len()),
|current_index| {
self.search_results
.iter()
.position(|i| i == &current_index)
.map_or_else(
|| format!("{}", self.search_results.len()),
|index| {
let len = format!("{}", self.search_results.len());
let len_width = len.chars().count();
format!("{:>len_width$}/{len:>len_width$}", index + 1)
},
)
},
))
}
/// Search through the logs for a matching string
pub fn search(&mut self, case_sensitive: bool) {
if let Some(search_term) = self.search_term.as_ref() {
let term = if case_sensitive {
search_term.to_owned()
} else {
search_term.to_lowercase()
};
self.search_results = self
.lines
.items
.iter()
.enumerate()
.filter_map(|(index, a)| {
a.lines
.iter()
.any(|b| {
b.spans.iter().any(|c| {
if case_sensitive {
c.content.contains(&term)
} else {
c.content.to_lowercase().contains(&term)
}
})
})
.then_some(index)
})
.collect();
if !self.search_results.is_empty() {
if let Some(currently_selected) = self.lines.state.selected()
&& !self.search_results.contains(&currently_selected)
{
self.lines.state.select(self.search_results.last().copied());
self.offset = 0;
} else {
self.lines.state.select(self.search_results.last().copied());
self.offset = 0;
}
}
} else {
self.search_results.clear();
}
}
/// Set a single char into the filter term
pub fn search_term_push(&mut self, c: char, case_sensitive: bool) {
if let Some(term) = self.search_term.as_mut() {
term.push(c);
} else {
self.search_term = Some(format!("{c}"));
}
self.search(case_sensitive);
}
/// Delete the final char of the filter term
pub fn search_term_pop(&mut self, case_sensitive: bool) {
if let Some(term) = self.search_term.as_mut() {
term.pop();
if term.is_empty() {
self.search_term = None;
}
}
self.search(case_sensitive);
}
/// Remove the filter completely
pub fn search_term_clear(&mut self) {
self.search_term = None;
self.search_results.clear();
}
/// Only allow a new log line to be inserted if the log timestamp isn't in the tz HashSet
pub fn insert(&mut self, line: Text<'static>, tz: LogsTz) {
pub fn insert(&mut self, line: Text<'static>, tz: LogsTz, case_sensitive: bool) {
if self.tz.insert(tz) {
self.max_log_len = self.max_log_len.max(line.width());
self.lines.items.push(line);
// Maybe - Ideally we'd re-render here
if self.search_term.is_some() {
self.search(case_sensitive);
}
}
}
@@ -748,21 +942,27 @@ impl Logs {
self.offset = self.offset.saturating_sub(1);
}
/// Scroll lines down by one
pub fn next(&mut self) {
self.lines.next();
}
/// Scroll lines up by one
pub fn previous(&mut self) {
self.lines.previous();
}
/// Go to the end of the lines
pub fn end(&mut self) {
self.lines.end();
}
/// Go to the start of the lines
pub fn start(&mut self) {
self.lines.start();
}
/// Get total number of log lines
pub const fn len(&self) -> usize {
self.lines.items.len()
}
@@ -938,7 +1138,7 @@ mod tests {
};
use crate::{
app_data::{ContainerImage, Logs, LogsTz, RunningState},
app_data::{ContainerImage, LogSearch, Logs, LogsTz, RunningState},
ui::log_sanitizer,
};
@@ -1075,9 +1275,9 @@ mod tests {
let mut logs = Logs::default();
let line = log_sanitizer::remove_ansi(input);
logs.insert(Text::from(line.clone()), tz.clone());
logs.insert(Text::from(line.clone()), tz.clone());
logs.insert(Text::from(line), tz);
logs.insert(Text::from(line.clone()), tz.clone(), true);
logs.insert(Text::from(line.clone()), tz.clone(), true);
logs.insert(Text::from(line), tz, true);
assert_eq!(logs.lines.items.len(), 1);
@@ -1085,9 +1285,9 @@ mod tests {
let (tz, _) = LogsTz::splitter(input);
let line = log_sanitizer::remove_ansi(input);
logs.insert(Text::from(line.clone()), tz.clone());
logs.insert(Text::from(line.clone()), tz.clone());
logs.insert(Text::from(line), tz);
logs.insert(Text::from(line.clone()), tz.clone(), true);
logs.insert(Text::from(line.clone()), tz.clone(), true);
logs.insert(Text::from(line), tz, true);
assert_eq!(logs.lines.items.len(), 2);
}
@@ -1150,15 +1350,15 @@ mod tests {
let input = "2023-01-14T19:13:30.783138328Z Hello world some long line".to_owned();
let (tz, _) = LogsTz::splitter(&input);
logs.insert(Text::from(input), tz);
logs.insert(Text::from(input), tz, true);
let input = "2023-01-14T19:13:31.783138328Z Hello world some line".to_owned();
let (tz, _) = LogsTz::splitter(&input);
logs.insert(Text::from(input), tz);
logs.insert(Text::from(input), tz, true);
let input = "2023-01-14T19:13:32.783138328Z Hello world".to_owned();
let (tz, _) = LogsTz::splitter(&input);
logs.insert(Text::from(input), tz);
logs.insert(Text::from(input), tz, true);
logs.offset = 43;
let result = logs.get_visible_logs(
@@ -1188,14 +1388,14 @@ mod tests {
let input = "short".to_owned();
let (tz, _) = LogsTz::splitter(&input);
logs.insert(Text::from(input), tz);
logs.insert(Text::from(input), tz, true);
let result = logs.get_scroll_title(10);
assert!(result.is_none());
let input = "2023-01-14T19:13:30.783138328Z Hello world some long line".to_owned();
let (tz, _) = LogsTz::splitter(&input);
logs.insert(Text::from(input), tz);
logs.insert(Text::from(input), tz, true);
let result = logs.get_scroll_title(10);
assert_eq!(result, Some(" 0/51 → ".to_owned()));
@@ -1211,4 +1411,119 @@ mod tests {
let result = logs.get_scroll_title(10);
assert_eq!(result, Some(" ← 51/51 ".to_owned()));
}
#[test]
/// Test the log search
fn test_logsearch() {
let mut logs = Logs::default();
for i in 1..=10 {
let input = if i % 2 == 0 {
format!("{i}, hello world some long line {i}")
} else {
format!("{i}, Hello world some long line {i}")
};
let (tz, _) = LogsTz::splitter(&input);
logs.insert(Text::from(input), tz, true);
}
logs.search_term_push('H', true);
assert_eq!(logs.search_results, [0, 2, 4, 6, 8]);
logs.search_term_clear();
logs.search_term_push('H', false);
assert_eq!(logs.search_results, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
}
#[test]
/// Test the LogSearch::From() methods
fn test_logsearch_from() {
let mut logs = Logs::default();
for i in 1..=10 {
let input = format!("{i}, Hello world some long line {i}");
let (tz, _) = LogsTz::splitter(&input);
logs.insert(Text::from(input), tz, true);
}
let log_search = LogSearch::from(&logs);
assert_eq!(
log_search,
LogSearch {
term: None,
result: None,
buttons: None
}
);
logs.search_term_push('H', true);
let log_search = LogSearch::from(&logs);
assert_eq!(
log_search,
LogSearch {
term: Some("H".to_owned()),
result: Some("10/10".to_owned()),
buttons: Some(crate::app_data::LogsButton::Previous)
}
);
logs.previous();
let log_search = LogSearch::from(&logs);
assert_eq!(
log_search,
LogSearch {
term: Some("H".to_owned()),
result: Some(" 9/10".to_owned()),
buttons: Some(crate::app_data::LogsButton::Both)
}
);
logs.start();
let log_search = LogSearch::from(&logs);
assert_eq!(
log_search,
LogSearch {
term: Some("H".to_owned()),
result: Some(" 1/10".to_owned()),
buttons: Some(crate::app_data::LogsButton::Next)
}
);
logs.search_term_push('H', true);
let log_search = LogSearch::from(&logs);
assert_eq!(
log_search,
LogSearch {
term: Some("HH".to_owned()),
result: None,
buttons: None
}
);
logs.search_term_clear();
logs.search_term_push('2', true);
let log_search = LogSearch::from(&logs);
assert_eq!(logs.lines.state.selected(), Some(1));
assert_eq!(
log_search,
LogSearch {
term: Some("2".to_owned()),
result: Some("1/1".to_owned()),
buttons: None
}
);
logs.next();
let log_search = LogSearch::from(&logs);
assert_eq!(
log_search,
LogSearch {
term: Some("2".to_owned()),
result: Some("1".to_owned()),
buttons: Some(crate::app_data::LogsButton::Previous)
}
);
}
}
+85 -64
View File
@@ -171,6 +171,19 @@ impl AppData {
(self.filter.by, self.filter.term.as_ref())
}
pub fn log_search_scroll(&mut self, np: &ScrollDirection) {
if let Some(i) = self.get_mut_selected_container() {
if i.logs.search_scroll(np).is_some() {
self.rerender.update_draw();
}
}
}
pub fn gen_log_search(&self) -> Option<LogSearch> {
self.get_selected_container()
.map(|i| i.logs.gen_log_search())
}
/// 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().is_none_or(|term| {
@@ -224,6 +237,31 @@ impl AppData {
}
}
pub fn logs_search_clear(&mut self) {
if let Some(selected_container) = self.get_mut_selected_container() {
selected_container.logs.search_term_clear();
self.rerender.update_draw();
}
}
/// Set a single char into the filter term
pub fn log_search_push(&mut self, c: char) {
let cs = self.config.log_search_case_sensitive;
if let Some(selected_container) = self.get_mut_selected_container() {
selected_container.logs.search_term_push(c, cs);
self.rerender.update_draw();
}
}
/// Delete the final char of the filter term
pub fn log_search_pop(&mut self) {
let cs = self.config.log_search_case_sensitive;
if let Some(selected_container) = self.get_mut_selected_container() {
selected_container.logs.search_term_pop(cs);
self.rerender.update_draw();
}
}
/// 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);
@@ -448,15 +486,8 @@ impl AppData {
self.rerender.update_draw();
}
/// Select the next container
pub fn containers_next(&mut self) {
self.containers.next();
self.rerender.update_draw();
}
/// select the previous container
pub fn containers_previous(&mut self) {
self.containers.previous();
pub fn containers_scroll(&mut self, scroll: &ScrollDirection) {
self.containers.scroll(scroll);
self.rerender.update_draw();
}
@@ -576,17 +607,10 @@ impl AppData {
}
/// Change selected choice of docker commands of selected container
pub fn docker_controls_next(&mut self) {
pub fn docker_controls_scroll(&mut self, scroll: &ScrollDirection) {
if let Some(i) = self.get_mut_selected_container() {
i.docker_controls.next();
self.rerender.update_draw();
}
}
/// Change selected choice of docker commands of selected container
pub fn docker_controls_previous(&mut self) {
if let Some(i) = self.get_mut_selected_container() {
i.docker_controls.previous();
i.docker_controls.scroll(scroll);
// i.docker_controls.next();
self.rerender.update_draw();
}
}
@@ -643,34 +667,30 @@ impl AppData {
.and_then(|i| i.logs.get_scroll_title(width))
}
/// Increase the logs offset, basically moving an invisible cursor back
pub fn log_back(&mut self) {
if let Some(i) = self.get_mut_selected_container() {
i.logs.back();
self.rerender.update_draw();
}
}
/// Increase the logs offset, basically moving an invisible cursor forward
pub fn log_forward(&mut self, width: u16) {
if let Some(i) = self.get_mut_selected_container() {
i.logs.forward(width);
self.rerender.update_draw();
pub fn logs_horizontal_scroll(&mut self, sd: &ScrollDirection, width: u16) {
match sd {
ScrollDirection::Next => {
if let Some(i) = self.get_mut_selected_container() {
i.logs.forward(width);
self.rerender.update_draw();
}
}
ScrollDirection::Previous => {
if let Some(i) = self.get_mut_selected_container() {
i.logs.back();
self.rerender.update_draw();
}
}
}
}
/// select next selected log line
pub fn log_next(&mut self) {
pub fn log_scroll(&mut self, scroll: &ScrollDirection) {
if let Some(i) = self.get_mut_selected_container() {
i.logs.next();
self.rerender.update_draw();
}
}
/// select previous selected log line
pub fn log_previous(&mut self) {
if let Some(i) = self.get_mut_selected_container() {
i.logs.previous();
match scroll {
ScrollDirection::Next => i.logs.next(),
ScrollDirection::Previous => i.logs.previous(),
}
self.rerender.update_draw();
}
}
@@ -857,7 +877,7 @@ impl AppData {
// If removed container is currently selected, then change selected to previous
// This will default to 0 in any edge cases
if self.containers.state.selected().is_some() {
self.containers.previous();
self.containers.scroll(&ScrollDirection::Previous);
}
// Check is some, else can cause out of bounds error, if containers get removed before a docker update
if self.containers.items.get(index).is_some() {
@@ -959,6 +979,8 @@ impl AppData {
let format = self.config.timestamp_format.clone();
let config_tz = self.config.timezone.clone();
let cs = self.config.log_search_case_sensitive;
let show_timestamp = self.config.show_timestamp;
if let Some(container) = self.get_any_container_by_id(id) {
@@ -985,7 +1007,7 @@ impl AppData {
} else {
log_sanitizer::remove_ansi(&i)
};
container.logs.insert(Text::from(lines), log_tz);
container.logs.insert(Text::from(lines), log_tz, cs);
}
// Set the logs selected row for each container
@@ -1422,7 +1444,7 @@ mod tests {
);
// Calling previous when at start has no effect
app_data.containers_previous();
app_data.containers_scroll(&ScrollDirection::Previous);
let result = app_data.get_selected_container_id();
assert_eq!(result, Some(ContainerId::from("1")));
let result = app_data.get_selected_container_id_state_name();
@@ -1445,7 +1467,7 @@ mod tests {
// Advance list state by 1
app_data.containers_start();
app_data.containers_next();
app_data.containers.scroll(&ScrollDirection::Next);
let result = app_data.get_container_state();
assert_eq!(result.selected(), Some(1));
@@ -1489,7 +1511,7 @@ mod tests {
);
// Calling previous when at end has no effect
app_data.containers_next();
app_data.containers.scroll(&ScrollDirection::Next);
let result = app_data.get_selected_container_id();
assert_eq!(result, Some(ContainerId::from("3")));
let result = app_data.get_selected_container_id_state_name();
@@ -1510,7 +1532,7 @@ mod tests {
let mut app_data = gen_appdata(&containers);
app_data.containers_end();
app_data.containers_previous();
app_data.containers.scroll(&ScrollDirection::Previous);
let result = app_data.get_container_state();
assert_eq!(result.selected(), Some(1));
assert_eq!(result.offset(), 0);
@@ -1526,7 +1548,7 @@ mod tests {
assert_eq!(result, None);
app_data.containers.start();
app_data.containers.next();
app_data.containers.scroll(&ScrollDirection::Next);
let result = app_data.get_selected_container();
assert_eq!(result, Some(&containers[1]));
@@ -1613,7 +1635,7 @@ mod tests {
let mut app_data = gen_appdata(&containers);
app_data.containers_start();
app_data.docker_controls_start();
app_data.docker_controls_next();
app_data.docker_controls_scroll(&ScrollDirection::Next);
let result = app_data.selected_docker_controls();
assert_eq!(result, Some(DockerCommand::Restart));
@@ -1631,7 +1653,7 @@ mod tests {
assert_eq!(result, Some(DockerCommand::Delete));
// Next has no effect when at end
app_data.docker_controls_next();
app_data.docker_controls_scroll(&ScrollDirection::Next);
let result = app_data.selected_docker_controls();
assert_eq!(result, Some(DockerCommand::Delete));
}
@@ -1643,14 +1665,14 @@ mod tests {
let mut app_data = gen_appdata(&containers);
app_data.containers_start();
app_data.docker_controls_end();
app_data.docker_controls_previous();
app_data.docker_controls_scroll(&ScrollDirection::Previous);
let result = app_data.selected_docker_controls();
assert_eq!(result, Some(DockerCommand::Stop));
// previous has no effect when at start
app_data.docker_controls_start();
app_data.docker_controls_previous();
app_data.docker_controls_scroll(&ScrollDirection::Previous);
let result = app_data.selected_docker_controls();
assert_eq!(result, Some(DockerCommand::Pause));
}
@@ -1914,7 +1936,7 @@ mod tests {
assert_eq!(result, " 3/3 - container_1 - image_1");
// Change log state to no longer be at the end
app_data.log_previous();
app_data.log_scroll(&ScrollDirection::Previous);
let result = app_data.get_log_title();
assert_eq!(result, " 2/3 - container_1 - image_1");
}
@@ -1935,7 +1957,7 @@ mod tests {
assert_eq!(result, " - container_1 - image_1");
// change container
app_data.containers_next();
app_data.containers_scroll(&ScrollDirection::Next);
let result = app_data.get_log_title();
assert_eq!(result, " - container_2 - image_2");
@@ -1946,7 +1968,7 @@ mod tests {
assert_eq!(result, " 3/3 - container_2 - image_2");
// Change log state to no longer be at the end
app_data.log_previous();
app_data.log_scroll(&ScrollDirection::Previous);
let result = app_data.get_log_title();
assert_eq!(result, " 2/3 - container_2 - image_2");
}
@@ -2053,8 +2075,7 @@ mod tests {
let result = app_data.get_log_title();
assert_eq!(result, " 1/3 - container_1 - image_1");
app_data.log_next();
app_data.log_scroll(&ScrollDirection::Next);
let result = app_data.get_log_state();
assert!(result.is_some());
assert_eq!(result.as_ref().unwrap().selected(), Some(1));
@@ -2063,7 +2084,7 @@ mod tests {
let result = app_data.get_log_title();
assert_eq!(result, " 2/3 - container_1 - image_1");
app_data.log_next();
app_data.log_scroll(&ScrollDirection::Next);
let result = app_data.get_log_state();
assert!(result.is_some());
assert_eq!(result.as_ref().unwrap().selected(), Some(2));
@@ -2071,7 +2092,7 @@ mod tests {
let result = app_data.get_log_title();
assert_eq!(result, " 3/3 - container_1 - image_1");
app_data.log_next();
app_data.log_scroll(&ScrollDirection::Next);
let result = app_data.get_log_state();
assert!(result.is_some());
@@ -2102,7 +2123,7 @@ mod tests {
let result = app_data.get_log_title();
assert_eq!(result, " 3/3 - container_1 - image_1");
app_data.log_previous();
app_data.log_scroll(&ScrollDirection::Previous);
let result = app_data.get_log_state();
assert!(result.is_some());
@@ -2111,7 +2132,7 @@ mod tests {
let result = app_data.get_log_title();
assert_eq!(result, " 2/3 - container_1 - image_1");
app_data.log_previous();
app_data.log_scroll(&ScrollDirection::Previous);
let result = app_data.get_log_state();
assert!(result.is_some());
assert_eq!(result.as_ref().unwrap().selected(), Some(0));
@@ -2119,7 +2140,7 @@ mod tests {
let result = app_data.get_log_title();
assert_eq!(result, " 1/3 - container_1 - image_1");
app_data.log_previous();
app_data.log_scroll(&ScrollDirection::Previous);
let result = app_data.get_log_state();
assert!(result.is_some());
assert_eq!(result.as_ref().unwrap().selected(), Some(0));
@@ -2413,7 +2434,7 @@ mod tests {
}
for _ in 0..=500 {
app_data.log_next();
app_data.log_scroll(&ScrollDirection::Next);
}
let result = app_data.get_logs(
Size {