From efbca409b4a9f08510f91a13c69ec76df62cc31c Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Tue, 14 Apr 2026 14:47:13 +0200 Subject: [PATCH 01/10] ui: more tests --- src/ui.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index 88babc0..e7e87a6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -591,3 +591,26 @@ impl App { frame.render_widget(preview, preview_area); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_filter_from_str_roundtrip() { + let cases = [ + ("duplicates", EventFilter::Duplicates), + ("session_id", EventFilter::SessionId), + ("folder", EventFilter::Folder), + ("exit_code_success", EventFilter::ExitCodeSuccess), + ]; + for (input, expected) in cases { + assert_eq!(input.parse::().unwrap(), expected); + } + } + + #[test] + fn event_filter_from_str_unknown() { + assert!("unknown".parse::().is_err()); + } +} From f61e9242ae4e8ba4989b67b92bffaefe633eb7c4 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Tue, 14 Apr 2026 15:26:06 +0200 Subject: [PATCH 02/10] matcher: indexer test --- src/matcher.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/matcher.rs b/src/matcher.rs index 789794e..1040745 100644 --- a/src/matcher.rs +++ b/src/matcher.rs @@ -313,7 +313,7 @@ impl FuzzyIndex { } } - /// crates an index from a matcher result + /// crates an index from a sorted matcher result pub fn new(scored_indices: Vec<(usize, i64)>, highlight_indices: Vec>) -> Self { let (indices, scores) = scored_indices.into_iter().unzip(); Self { @@ -374,6 +374,77 @@ impl FuzzyIndex { mod tests { use super::*; + #[test] + fn fuzzy_index_identity_get() { + let index = FuzzyIndex::identity(); + assert_eq!(index.get(0), Some(0)); + assert_eq!(index.get(42), Some(42)); + } + + #[test] + fn fuzzy_index_identity_len_and_empty() { + let index = FuzzyIndex::identity(); + assert_eq!(index.len(), None); + assert!(index.is_empty()); + } + + #[test] + fn fuzzy_index_identity_first_n() { + let index = FuzzyIndex::identity(); + let result: Vec = index.first_n(3).collect(); + assert_eq!(result, vec![0, 1, 2]); + } + + #[test] + fn fuzzy_index_identity_no_highlights_or_scores() { + let index = FuzzyIndex::identity(); + assert_eq!(index.highlight_indices(0), None); + assert_eq!(index.matcher_score(0), None); + } + + #[test] + fn fuzzy_index_filtered_get() { + let index = FuzzyIndex::new(vec![(5, 100), (2, 50)], vec![vec![0, 1], vec![3]]); + assert_eq!(index.get(0), Some(5)); + assert_eq!(index.get(1), Some(2)); + assert_eq!(index.get(2), None); + } + + #[test] + fn fuzzy_index_filtered_len() { + let index = FuzzyIndex::new(vec![(5, 100), (2, 50)], vec![vec![], vec![]]); + assert_eq!(index.len(), Some(2)); + assert!(!index.is_empty()); + } + + #[test] + fn fuzzy_index_filtered_first_n() { + let index = FuzzyIndex::new( + vec![(5, 100), (2, 50), (8, 10)], + vec![vec![], vec![], vec![]], + ); + let result: Vec = index.first_n(2).collect(); + assert_eq!(result, vec![5, 2]); + } + + #[test] + fn fuzzy_index_filtered_first_n_clamps_to_available() { + let index = FuzzyIndex::new(vec![(1, 10)], vec![vec![]]); + let result: Vec = index.first_n(10).collect(); + assert_eq!(result, vec![1]); + } + + #[test] + fn fuzzy_index_filtered_scores_and_highlights() { + let index = FuzzyIndex::new(vec![(5, 100), (2, 50)], vec![vec![0, 1], vec![3, 4]]); + assert_eq!(index.matcher_score(0), Some(100)); + assert_eq!(index.matcher_score(1), Some(50)); + assert_eq!(index.matcher_score(2), None); + assert_eq!(index.highlight_indices(0), Some(&vec![0, 1])); + assert_eq!(index.highlight_indices(1), Some(&vec![3, 4])); + assert_eq!(index.highlight_indices(2), None); + } + #[test] fn single_term_matches() { let engine = FuzzyEngine::new("git".to_string()); From c1a9fa8612c55ea92db0bc000ee1a8015afea555 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Tue, 14 Apr 2026 16:09:43 +0200 Subject: [PATCH 03/10] lib: tests --- src/lib.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 499e035..d10ac13 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,9 @@ -use std::{collections::HashSet, fs::File, os::fd::AsRawFd, path::PathBuf}; +use std::{ + collections::HashSet, + fs::File, + os::fd::AsRawFd, + path::{Path, PathBuf}, +}; use anyhow::anyhow; use glob::glob; @@ -42,15 +47,14 @@ pub fn mmap(file: &File) -> &'_ [u8] { } } -/// discover all parsable osh files under `~/.osh` for a specific format -pub fn osh_files(kind: formats::Kind) -> anyhow::Result> { - // TODO when can this really fail? - let home_dir = home::home_dir().ok_or(anyhow!("no home directory"))?; - let home = home_dir - .to_str() - .ok_or(anyhow!("home directory contains invalid chars"))?; - let pattern = format!("{home}/.osh/**/*.{}", kind.extension()); - +/// discover all osh files of `kind` under `root`, recursively. +fn discover_files(root: &Path, kind: &formats::Kind) -> anyhow::Result> { + let pattern = format!( + "{}/**/*.{}", + root.to_str() + .ok_or(anyhow!("root path contains invalid chars"))?, + kind.extension() + ); let files = match glob(&pattern) { Err(_) => unreachable!("pattern is valid"), Ok(matches) => matches @@ -58,10 +62,15 @@ pub fn osh_files(kind: formats::Kind) -> anyhow::Result> { .filter_map(|path| path.canonicalize().ok()) .collect(), }; - Ok(files) } +/// discover all parsable osh files under `~/.osh` for a specific format +pub fn osh_files(kind: formats::Kind) -> anyhow::Result> { + let home_dir = home::home_dir().ok_or(anyhow!("no home directory"))?; + discover_files(&home_dir.join(".osh"), &kind) +} + /// load all binary osh files in `~/.osh` and return a merged and sorted vector of all events pub fn load_sorted() -> anyhow::Result> { let oshs = osh_files(Kind::Rmp)?; @@ -79,3 +88,51 @@ pub fn load_sorted() -> anyhow::Result> { all_items.par_sort_unstable_by(|a, b| b.cmp(a)); Ok(all_items) } + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn mmap_reads_file_contents() { + let mut file = tempfile::tempfile().unwrap(); + let data = b"hello mmap"; + file.write_all(data).unwrap(); + let mapped = mmap(&file); + assert_eq!(mapped, data); + } + + #[test] + fn discover_mixed_extensions() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("machine1")).unwrap(); + std::fs::File::create(dir.path().join("machine1/history.bosh")).unwrap(); + std::fs::File::create(dir.path().join("machine1/history.osh")).unwrap(); + + let found = discover_files(dir.path(), &Kind::Rmp).unwrap(); + assert_eq!(found.len(), 1); + assert!(found.iter().all(|p| p.extension().unwrap() == "bosh")); + } + + #[test] + fn discover_empty_dir() { + let dir = TempDir::new().unwrap(); + let found = discover_files(dir.path(), &Kind::Rmp).unwrap(); + assert!(found.is_empty()); + } + + #[test] + fn discover_nested() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("a/b")).unwrap(); + std::fs::File::create(dir.path().join("a/one.bosh")).unwrap(); + std::fs::File::create(dir.path().join("a/b/two.bosh")).unwrap(); + + let found = discover_files(dir.path(), &Kind::Rmp).unwrap(); + assert_eq!(found.len(), 2); + } +} From bb2bbd18a84e22742cbecb78ed855d401cfb880d Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Wed, 15 Apr 2026 08:00:54 +0200 Subject: [PATCH 04/10] event: add tests and fix a bug while adding tests I noticed that the conversion from JsonLineEvent has a bug: the computation of the endtime from the duration assumed that the duration field is in millis as well. But duration was measured in seconds so the conversion needs to take this into account. This commit fixes the conversion but converted files should be re-converted to have the correct endtime in converted history files. Note that sorting of the events is not affected as the ordering is the same. --- src/event.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/event.rs b/src/event.rs index a86e421..572de89 100644 --- a/src/event.rs +++ b/src/event.rs @@ -47,10 +47,11 @@ impl From for Event { /// and this is only used to convert old history files to the binary format. fn from(event: JsonLineEvent) -> Self { let timestamp = event.timestamp.timestamp_millis(); + let endtime = timestamp + (event.duration * 1000.) as i64; Self { timestamp_millis: timestamp, command: event.command, - endtime: (timestamp + (event.duration as i64)), + endtime, exit_code: event.exit_code, folder: event.folder, machine: event.machine, @@ -78,3 +79,78 @@ impl Event { writer.write(self) } } + +#[cfg(test)] +mod tests { + use std::cmp::Ordering; + + use super::*; + + fn event_with_endtime(endtime: i64) -> Event { + Event { + timestamp_millis: 0, + command: String::new(), + endtime, + exit_code: 0, + folder: String::new(), + machine: String::new(), + session: String::new(), + } + } + + #[test] + fn cmp_orders_by_endtime() { + let earlier = event_with_endtime(100); + let later = event_with_endtime(200); + assert_eq!(earlier.cmp(&later), Ordering::Less); + assert_eq!(later.cmp(&earlier), Ordering::Greater); + assert_eq!(earlier.cmp(&earlier), Ordering::Equal); + } + + #[test] + fn partial_cmp_always_some() { + let a = event_with_endtime(100); + let b = event_with_endtime(200); + assert_eq!(a.partial_cmp(&b), Some(Ordering::Less)); + assert_eq!(b.partial_cmp(&a), Some(Ordering::Greater)); + assert_eq!(a.partial_cmp(&a), Some(Ordering::Equal)); + } + + #[allow(deprecated)] + #[test] + fn from_json_line_event() { + let timestamp = chrono::DateTime::from_timestamp_millis(1_000_000_000_000).unwrap(); + let timestamp = timestamp.with_timezone(&chrono::Local); + let json_event = crate::formats::json_lines::JsonLineEvent { + timestamp, + command: "sleep 5".to_string(), + duration: 5.0, + exit_code: 0, + folder: "/".to_string(), + machine: "m".to_string(), + session: "s".to_string(), + }; + let event = Event::from(json_event); + assert_eq!(event.timestamp_millis, 1_000_000_000_000); + assert_eq!(event.endtime, 1_000_000_000_000_i64 + 5 * 1000); + assert_eq!(event.command, "sleep 5"); + assert_eq!(event.exit_code, 0); + assert_eq!(event.folder, "/"); + assert_eq!(event.machine, "m"); + assert_eq!(event.session, "s"); + } + + #[test] + fn sort_by_endtime() { + let mut events = vec![ + event_with_endtime(300), + event_with_endtime(100), + event_with_endtime(200), + ]; + events.sort(); + assert_eq!( + events.iter().map(|e| e.endtime).collect::>(), + vec![100, 200, 300] + ); + } +} From 394f70c014f13c8941849c49a2ae259c230cd2fe Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Wed, 15 Apr 2026 08:07:56 +0200 Subject: [PATCH 05/10] codecov: ignore deprecated --- .github/codecov.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/codecov.yml b/.github/codecov.yml index cd5ce8f..7c108bf 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -11,9 +11,9 @@ coverage: # Avoid false negatives threshold: 1% -# Test files aren't important for coverage ignore: - - "tests" + - "tests" # test fixtures + - "src/formats/json_lines.rs" # deprecated, migration only # Make comments less noisy comment: From 550d31594e64a3abab6a8fc6310186c7a61e5e5a Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Wed, 15 Apr 2026 15:47:40 +0200 Subject: [PATCH 06/10] ui: more tests --- src/ui.rs | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index e7e87a6..f0542c5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -596,6 +596,141 @@ impl App { mod tests { use super::*; + fn make_app(input: &str) -> App { + let character_index = input.chars().count(); + App { + input: input.to_string(), + character_index, + history: Vec::new(), + indexer: crate::matcher::FuzzyIndex::identity(), + reader: EventReader::new(), + events: Vec::new(), + selected_index: 0, + filters: HashSet::new(), + folder: String::new(), + session_id: None, + show_score: false, + } + } + + #[test] + fn delete_word_basic() { + let mut app = make_app("hello world"); + app.delete_word(); + assert_eq!(app.input, "hello "); + assert_eq!(app.character_index, 6); + } + + #[test] + fn delete_word_trailing_spaces() { + let mut app = make_app("hello "); + app.delete_word(); + assert_eq!(app.input, ""); + assert_eq!(app.character_index, 0); + } + + #[test] + fn delete_word_single_word() { + let mut app = make_app("hello"); + app.delete_word(); + assert_eq!(app.input, ""); + assert_eq!(app.character_index, 0); + } + + #[test] + fn delete_word_at_start_is_noop() { + let mut app = make_app("hello"); + app.character_index = 0; + app.delete_word(); + assert_eq!(app.input, "hello"); + assert_eq!(app.character_index, 0); + } + + #[test] + fn delete_word_mid_word() { + let mut app = make_app("hello world"); + app.character_index = 7; // cursor between 'w' and 'o' in "world" + app.delete_word(); + assert_eq!(app.input, "hello orld"); + assert_eq!(app.character_index, 6); + } + + #[test] + fn byte_index_ascii() { + let app = make_app("hello"); + assert_eq!(app.byte_index(), 5); + } + + #[test] + fn byte_index_multibyte() { + // "é" is 2 bytes; cursor after it should give byte index 2 + let mut app = make_app("é"); + app.character_index = 1; + assert_eq!(app.byte_index(), 2); + } + + #[test] + fn byte_index_at_start() { + let mut app = make_app("hello"); + app.character_index = 0; + assert_eq!(app.byte_index(), 0); + } + + #[test] + fn toggle_filter_adds_and_removes() { + let mut app = make_app(""); + app.toggle_filter(EventFilter::Duplicates); + assert!(app.filters.contains(&EventFilter::Duplicates)); + app.toggle_filter(EventFilter::Duplicates); + assert!(!app.filters.contains(&EventFilter::Duplicates)); + } + + #[test] + fn active_filters_empty() { + let app = make_app(""); + assert_eq!(app.active_filters(), ""); + } + + #[test] + fn active_filters_shows_abbreviations() { + let mut app = make_app(""); + app.toggle_filter(EventFilter::ExitCodeSuccess); + app.toggle_filter(EventFilter::Folder); + let filters = app.active_filters(); + assert!(filters.contains('E')); + assert!(filters.contains('F')); + } + + #[test] + fn move_selection_up_increments() { + let mut app = make_app(""); + app.move_selection_up(10); + assert_eq!(app.selected_index, 1); + } + + #[test] + fn move_selection_up_clamps_to_height() { + let mut app = make_app(""); + app.selected_index = 6; + app.move_selection_up(8); // max = 8 - 3 = 5 + assert_eq!(app.selected_index, 5); + } + + #[test] + fn move_selection_down_decrements() { + let mut app = make_app(""); + app.selected_index = 3; + app.move_selection_down(); + assert_eq!(app.selected_index, 2); + } + + #[test] + fn move_selection_down_clamps_at_zero() { + let mut app = make_app(""); + app.move_selection_down(); + assert_eq!(app.selected_index, 0); + } + #[test] fn event_filter_from_str_roundtrip() { let cases = [ From 428c2143b96c95e62547c14f53712de5e9c3aac4 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Thu, 16 Apr 2026 11:35:22 +0200 Subject: [PATCH 07/10] ui: more tests --- src/ui.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index f0542c5..57c91fd 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -731,6 +731,62 @@ mod tests { assert_eq!(app.selected_index, 0); } + #[test] + fn move_cursor_left_decrements() { + let mut app = make_app("hello"); + app.move_cursor_left(); + assert_eq!(app.character_index, 4); + } + + #[test] + fn move_cursor_left_clamps_at_zero() { + let mut app = make_app("hello"); + app.character_index = 0; + app.move_cursor_left(); + assert_eq!(app.character_index, 0); + } + + #[test] + fn move_cursor_right_increments() { + let mut app = make_app("hello"); + app.character_index = 0; + app.move_cursor_right(); + assert_eq!(app.character_index, 1); + } + + #[test] + fn move_cursor_right_clamps_at_end() { + let mut app = make_app("hello"); + app.move_cursor_right(); + assert_eq!(app.character_index, 5); + } + + #[test] + fn delete_char_basic() { + let mut app = make_app("hello"); + app.delete_char(); + assert_eq!(app.input, "hell"); + assert_eq!(app.character_index, 4); + } + + #[test] + fn delete_char_at_start_is_noop() { + let mut app = make_app("hello"); + app.character_index = 0; + app.delete_char(); + assert_eq!(app.input, "hello"); + assert_eq!(app.character_index, 0); + } + + #[test] + fn delete_char_multibyte() { + let mut app = make_app("héllo"); + app.character_index = 2; // cursor after 'é' + app.delete_char(); + assert_eq!(app.input, "hllo"); + assert_eq!(app.character_index, 1); + } + #[test] fn event_filter_from_str_roundtrip() { let cases = [ From 06ad651a8633df0088540e9198a7adf0eda5e8e5 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Thu, 16 Apr 2026 11:42:59 +0200 Subject: [PATCH 08/10] ui: more tests --- src/ui.rs | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index 57c91fd..8bc1a91 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -731,6 +731,64 @@ mod tests { assert_eq!(app.selected_index, 0); } + #[test] + fn enter_char_appends_at_end() { + let mut app = make_app("hell"); + app.enter_char('o'); + assert_eq!(app.input, "hello"); + assert_eq!(app.character_index, 5); + } + + #[test] + fn enter_char_inserts_at_start() { + let mut app = make_app("ello"); + app.character_index = 0; + app.enter_char('h'); + assert_eq!(app.input, "hello"); + assert_eq!(app.character_index, 1); + } + + #[test] + fn enter_char_inserts_in_middle() { + let mut app = make_app("hllo"); + app.character_index = 1; + app.enter_char('e'); + assert_eq!(app.input, "hello"); + assert_eq!(app.character_index, 2); + } + + #[test] + fn enter_char_multibyte() { + let mut app = make_app("h"); + app.enter_char('é'); + assert_eq!(app.input, "hé"); + assert_eq!(app.character_index, 2); + } + + #[test] + fn collect_new_events_drains_channel() { + let (sender, receiver) = crossbeam_channel::unbounded(); + let mut app = make_app(""); + app.reader = EventReader::new().start(receiver); + + let event = Arc::new(Event { + timestamp_millis: 0, + command: "git status".to_string(), + endtime: 1000, + exit_code: 0, + folder: "/".to_string(), + machine: "m".to_string(), + session: "s".to_string(), + }); + sender.send(event).unwrap(); + drop(sender); // closing the channel lets us wait for the thread to drain it + std::thread::sleep(std::time::Duration::from_millis(10)); + + app.collect_new_events(); + assert_eq!(app.events.len(), 1); + assert_eq!(app.events[0].command, "git status"); + } + #[test] fn move_cursor_left_decrements() { let mut app = make_app("hello"); From 0dd42057ea25af7ab6130887734fb2a08bf25052 Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Thu, 16 Apr 2026 11:46:30 +0200 Subject: [PATCH 09/10] ui: test all filters --- src/ui.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index 8bc1a91..9d08b0f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -694,11 +694,15 @@ mod tests { #[test] fn active_filters_shows_abbreviations() { let mut app = make_app(""); + app.toggle_filter(EventFilter::Duplicates); app.toggle_filter(EventFilter::ExitCodeSuccess); app.toggle_filter(EventFilter::Folder); + app.toggle_filter(EventFilter::SessionId); let filters = app.active_filters(); + assert!(filters.contains('U')); assert!(filters.contains('E')); assert!(filters.contains('F')); + assert!(filters.contains('S')); } #[test] From 0d5336cd43fd85a0fcd607f1d35675dc5145cf6d Mon Sep 17 00:00:00 2001 From: Yves Ineichen Date: Thu, 16 Apr 2026 12:53:39 +0200 Subject: [PATCH 10/10] ui: remove history "cache" and use events when rendering --- src/ui.rs | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 9d08b0f..5ed3ba1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -164,9 +164,7 @@ struct App { input: String, /// position of cursor in the editor area. character_index: usize, - /// display entries: (time-ago label, command) pairs derived from `events` - history: Vec<(String, String)>, - /// indices into history sorted according to fuzzer score if we have a query + /// indices into events sorted according to fuzzy score if we have a query indexer: FuzzyIndex, /// reader for collecting events from background thread reader: EventReader, @@ -193,7 +191,6 @@ impl App { let character_index = query.len(); Self { input: query, - history: Vec::new(), indexer: FuzzyIndex::identity(), character_index, reader, @@ -355,18 +352,6 @@ impl App { new_cursor_pos.clamp(0, self.input.chars().count()) } - fn update_display(&mut self) { - self.history.clear(); - let f = timeago::Formatter::new(); - let now = Utc::now().timestamp_millis(); - for event in &self.events { - let d = std::time::Duration::from_millis((now - event.endtime) as u64); - let ago = f.convert(d); - // TODO clone - self.history.push((ago, event.command.clone())); - } - } - fn toggle_filter(&mut self, event: EventFilter) { if self.filters.contains(&event) { self.filters.remove(&event); @@ -394,7 +379,6 @@ impl App { terminal: &mut Terminal>, ) -> anyhow::Result> { self.collect_new_events(); - self.update_display(); terminal.draw(|frame| self.render(frame))?; loop { @@ -469,7 +453,6 @@ impl App { self.collect_new_events(); if self.events.len() != events_before { self.run_matcher(); - self.update_display(); terminal.draw(|frame| self.render(frame))?; } } @@ -487,14 +470,20 @@ impl App { let available_height = history_area.height.saturating_sub(1) as usize; + let now = Utc::now().timestamp_millis(); + let timeago_fmt = timeago::Formatter::new(); let history: Vec = self .indexer - .first_n(available_height.min(self.history.len())) + .first_n(available_height.min(self.events.len())) .enumerate() .rev() .filter_map(|(i, idx)| { // TODO should always be Some(...): skip, report, log otherwise? - let (ago, command) = self.history.get(idx)?; + let event = self.events.get(idx)?; + let ago = timeago_fmt.convert(std::time::Duration::from_millis( + (now - event.endtime) as u64, + )); + let command = &event.command; let mut spans = Vec::new(); spans.push(Span::raw(format!("{ago} -- "))); if let Some(hl_indides) = self.indexer.highlight_indices(idx) { @@ -524,7 +513,7 @@ impl App { spans.push(Span::raw(text)); } } else { - spans.push(Span::raw(command.clone())); + spans.push(Span::raw(command.as_str())); } if self.show_score @@ -546,7 +535,7 @@ impl App { frame.render_widget(history_widget, history_area); let filtered = self.indexer.len().unwrap_or(self.events.len()); - let status_text = format!("{filtered}/{}", self.history.len()); + let status_text = format!("{filtered}/{}", self.events.len()); let status_line = Line::from(vec![Span::raw(" "), Span::raw(status_text)]); let filters = format!("[{}] ", self.active_filters()); let status_line_chunks = Layout::default() @@ -601,7 +590,6 @@ mod tests { App { input: input.to_string(), character_index, - history: Vec::new(), indexer: crate::matcher::FuzzyIndex::identity(), reader: EventReader::new(), events: Vec::new(),