From 83d93d2e77fb9d3ef7e89fa22a4dd4f276e4dad1 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 19 Jul 2026 16:44:20 +0900 Subject: [PATCH 1/5] Preserve applied search inputs for rebuilding --- src/widget/commit_list.rs | 172 +++++++++++++++++++++++++++++++++++++- 1 file changed, 171 insertions(+), 1 deletion(-) diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index d9fb7b24..9cec7d34 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -57,9 +57,18 @@ pub enum SearchState { Applied { match_index: usize, total_match: usize, + ignore_case: bool, + fuzzy: bool, }, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchRefreshContext { + query: String, + ignore_case: bool, + fuzzy: bool, +} + impl SearchState { fn update_match_index(&mut self, index: usize) { match self { @@ -503,7 +512,13 @@ impl<'a> CommitListState<'a> { } pub fn apply_search(&mut self) { - if let SearchState::Searching { match_index, .. } = self.search_state { + if let SearchState::Searching { + match_index, + ignore_case, + fuzzy, + .. + } = self.search_state + { if self.search_input.value().is_empty() { self.search_state = SearchState::Inactive; } else { @@ -511,11 +526,45 @@ impl<'a> CommitListState<'a> { self.search_state = SearchState::Applied { match_index, total_match, + ignore_case, + fuzzy, }; } } } + pub fn search_refresh_context(&self) -> Option { + if let SearchState::Applied { + ignore_case, fuzzy, .. + } = self.search_state + { + Some(SearchRefreshContext { + query: self.search_input.value().into(), + ignore_case, + fuzzy, + }) + } else { + None + } + } + + pub fn restore_search(&mut self, context: &SearchRefreshContext) { + self.search_input = Input::new(context.query.clone()); + self.update_search_matches(context.ignore_case, context.fuzzy); + + let total_match = self.search_matches.iter().filter(|m| m.matched()).count(); + self.search_state = SearchState::Applied { + match_index: 0, + total_match, + ignore_case: context.ignore_case, + fuzzy: context.fuzzy, + }; + + if total_match > 0 { + self.select_current_or_next_match_index(self.current_selected_index()); + } + } + pub fn cancel_search(&mut self) { if let SearchState::Searching { .. } | SearchState::Applied { .. } = self.search_state { self.search_state = SearchState::Inactive; @@ -1155,8 +1204,129 @@ fn calc_cell_widths( #[cfg(test)] mod tests { + use std::path::PathBuf; + + use ratatui::crossterm::event::KeyCode; + + use crate::{ + color::GraphColorSet, + config::GraphColorConfig, + git::Repository, + graph::{calc_graph, CellWidthType, GraphImageWidthMode, GraphStyle}, + protocol::ImageProtocol, + }; + use super::*; + fn with_commit_list_state( + subjects: &[&str], + f: impl FnOnce(&mut CommitListState<'_>) -> R, + ) -> R { + let commits: Vec = subjects + .iter() + .enumerate() + .map(|(i, subject)| Commit { + commit_hash: CommitHash::from(format!("{:040x}", i + 1).as_str()), + subject: (*subject).into(), + ..Commit::default() + }) + .collect(); + let commit_hashes = commits.iter().map(|c| c.commit_hash.clone()).collect(); + let commit_map = commits + .into_iter() + .map(|c| (c.commit_hash.clone(), c)) + .collect(); + let repository = Repository::new( + PathBuf::new(), + commit_map, + FxHashMap::default(), + FxHashMap::default(), + FxHashMap::default(), + Head::None, + commit_hashes, + ); + let graph = calc_graph(&repository); + let graph_color_set = GraphColorSet::new(&GraphColorConfig::default()); + let graph_image_manager = GraphImageManager::new( + &graph, + &graph_color_set, + CellWidthType::Double, + GraphStyle::Rounded, + GraphImageWidthMode::Compact, + ImageProtocol::Iterm2, + ); + let commit_infos = graph + .commits + .iter() + .map(|commit| { + CommitInfo::new(commit, repository.refs(&commit.commit_hash), Color::Reset) + }) + .collect(); + let mut state = CommitListState::new( + commit_infos, + graph_image_manager, + 0, + repository.head(), + FxHashMap::default(), + false, + false, + ); + state.reset_height(subjects.len()); + f(&mut state) + } + + fn input_search_query(state: &mut CommitListState<'_>, query: &str) { + state.start_search(); + for c in query.chars() { + state.handle_search_input(KeyEvent::from(KeyCode::Char(c))); + } + } + + #[test] + fn test_restore_search_recalculates_matches_with_applied_options() { + let context = with_commit_list_state(&["Fix parser", "other"], |state| { + input_search_query(state, "fx"); + state.toggle_ignore_case(); + state.toggle_fuzzy(); + state.apply_search(); + + state.search_refresh_context().unwrap() + }); + + assert_eq!( + context, + SearchRefreshContext { + query: "fx".into(), + ignore_case: true, + fuzzy: true, + } + ); + + with_commit_list_state(&["unrelated", "FIX new", "fix parser"], |state| { + state.restore_search(&context); + + assert_eq!(state.search_refresh_context(), Some(context.clone())); + assert_eq!( + state.matched_query_string(), + Some(("Match 1 of 2 (query: \"fx\")".into(), true)) + ); + assert_eq!( + state.commits[state.current_selected_index()].commit.subject, + "FIX new" + ); + + state.select_next_match(); + assert_eq!( + state.matched_query_string(), + Some(("Match 2 of 2 (query: \"fx\")".into(), true)) + ); + assert_eq!( + state.commits[state.current_selected_index()].commit.subject, + "fix parser" + ); + }); + } + #[test] fn test_calc_cell_widths_all_columns() { let area_width = 80; From 5b11716d29ce7be5cb3b577550554c64f47c9b1e Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 19 Jul 2026 16:44:51 +0900 Subject: [PATCH 2/5] Restore search state after refresh --- src/view/list.rs | 4 ++++ src/view/views.rs | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/view/list.rs b/src/view/list.rs index 62fa3bd8..36fd080a 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -265,6 +265,7 @@ impl<'a> ListView<'a> { selected, height, scroll_to_top, + search_context, } = list_context; let list_state = self.as_mut_list_state(); list_state.reset_height(*height); @@ -276,5 +277,8 @@ impl<'a> ListView<'a> { list_state.scroll_up(); } } + if let Some(search_context) = search_context { + list_state.restore_search(search_context); + } } } diff --git a/src/view/views.rs b/src/view/views.rs index c8e42203..29ada6c2 100644 --- a/src/view/views.rs +++ b/src/view/views.rs @@ -10,7 +10,7 @@ use crate::{ detail::DetailView, help::HelpView, list::ListView, refs::RefsView, user_command::UserCommandView, }, - widget::commit_list::CommitListState, + widget::commit_list::{CommitListState, SearchRefreshContext}, }; #[derive(Debug, Default)] @@ -193,6 +193,7 @@ pub struct ListRefreshViewContext { pub selected: usize, pub height: usize, pub scroll_to_top: bool, + pub search_context: Option, } impl From<&CommitListState<'_>> for ListRefreshViewContext { @@ -202,11 +203,13 @@ impl From<&CommitListState<'_>> for ListRefreshViewContext { // If the selected commit is the top one and there is no offset, it means the list is already scrolled to the top. // In this case, we set scroll_to_top to true to indicate that the view should be scrolled to the top after refresh. let scroll_to_top = selected == 0 && offset == 0; + let search_context = list_state.search_refresh_context(); ListRefreshViewContext { commit_hash, selected, height, scroll_to_top, + search_context, } } } From c651ce10dba284192d5e067282d265eb5aa93dc4 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 19 Jul 2026 16:45:38 +0900 Subject: [PATCH 3/5] Keep matched commit position on search restore --- src/widget/commit_list.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index 9cec7d34..9d3b95c8 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -561,7 +561,13 @@ impl<'a> CommitListState<'a> { }; if total_match > 0 { - self.select_current_or_next_match_index(self.current_selected_index()); + let current_index = self.current_selected_index(); + if self.search_matches[current_index].matched() { + self.search_state + .update_match_index(self.search_matches[current_index].match_index); + } else { + self.select_next_match_index(current_index); + } } } @@ -1327,6 +1333,30 @@ mod tests { }); } + #[test] + fn test_restore_search_keeps_selected_match_position() { + let context = with_commit_list_state(&["fix"], |state| { + input_search_query(state, "fix"); + state.apply_search(); + state.search_refresh_context().unwrap() + }); + + with_commit_list_state(&["first", "second", "fix", "last"], |state| { + state.reset_height(2); + state.select_index(2); + state.scroll_up(); + assert_eq!(state.current_list_status(), (1, 1, 2)); + + state.restore_search(&context); + + assert_eq!(state.current_list_status(), (1, 1, 2)); + assert_eq!( + state.matched_query_string(), + Some(("Match 1 of 1 (query: \"fix\")".into(), true)) + ); + }); + } + #[test] fn test_calc_cell_widths_all_columns() { let area_width = 80; From d79b2c9ab6cda3da32f9876143ce687bf526ec62 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 19 Jul 2026 17:04:50 +0900 Subject: [PATCH 4/5] Keep selection when restored search does not match --- src/widget/commit_list.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index 9d3b95c8..f48fb39e 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -565,8 +565,6 @@ impl<'a> CommitListState<'a> { if self.search_matches[current_index].matched() { self.search_state .update_match_index(self.search_matches[current_index].match_index); - } else { - self.select_next_match_index(current_index); } } } @@ -1312,6 +1310,12 @@ mod tests { state.restore_search(&context); assert_eq!(state.search_refresh_context(), Some(context.clone())); + assert_eq!( + state.commits[state.current_selected_index()].commit.subject, + "unrelated" + ); + + state.select_next_match(); assert_eq!( state.matched_query_string(), Some(("Match 1 of 2 (query: \"fx\")".into(), true)) From e21967ec498ce1b7331f7b1b2d2ba6387c0efce0 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sun, 19 Jul 2026 18:13:02 +0900 Subject: [PATCH 5/5] Explain restored search match index --- src/widget/commit_list.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index f48fb39e..69186b6e 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -554,6 +554,7 @@ impl<'a> CommitListState<'a> { let total_match = self.search_matches.iter().filter(|m| m.matched()).count(); self.search_state = SearchState::Applied { + // The selected commit may not match after refresh; next/previous updates this value. match_index: 0, total_match, ignore_case: context.ignore_case,