diff --git a/assets/default-keybind.toml b/assets/default-keybind.toml index bcf847c0..46fd0346 100644 --- a/assets/default-keybind.toml +++ b/assets/default-keybind.toml @@ -29,6 +29,7 @@ go_to_previous = ["shift-n"] confirm = ["enter"] ref_list = ["tab"] search = ["/"] +search_target_toggle = ["ctrl-t"] ignore_case_toggle = ["ctrl-g"] fuzzy_toggle = ["ctrl-x"] refresh = ["shift-r"] diff --git a/config.schema.json b/config.schema.json index 69a6549d..962b17e0 100644 --- a/config.schema.json +++ b/config.schema.json @@ -79,6 +79,18 @@ "type": "object", "description": "Default search settings", "properties": { + "target": { + "type": "string", + "description": "The field to search by default.", + "enum": [ + "all", + "subject", + "author", + "ref", + "hash" + ], + "default": "all" + }, "ignore_case": { "type": "boolean", "description": "Whether to enable ignore case by default.", @@ -661,6 +673,9 @@ "search": { "$ref": "#/definitions/keybindArray" }, + "search_target_toggle": { + "$ref": "#/definitions/keybindArray" + }, "ignore_case_toggle": { "$ref": "#/definitions/keybindArray" }, diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md index 294d2c1c..8bfed0f0 100644 --- a/docs/src/configurations/config-file-format.md +++ b/docs/src/configurations/config-file-format.md @@ -14,6 +14,7 @@ initial_selection = "latest" mailmap = false [core.search] +target = "all" ignore_case = false fuzzy = false @@ -194,6 +195,19 @@ The width mode for each graph row image. - `fixed`: use the same full graph width for every row image - This can be used when you want to set a background color for graphs in environments that cannot correctly handle transparent images, or in environments where rendering does not work well when there are images of various widths. +### `core.search.target` + +The field to search when the application starts. The target can be toggled while the commit list is displayed. + +- type: `string` (enum) +- default: `all` +- possible values: + - `all`: Search refs, commit subjects, author names, and short commit hashes + - `subject`: Search commit subjects + - `author`: Search author names + - `ref`: Search branch, remote branch, and tag names + - `hash`: Search short commit hashes + ### `core.search.ignore_case` Whether to enable ignore case when the application starts. The option can be toggled while the commit list is displayed. diff --git a/docs/src/keybindings/index.md b/docs/src/keybindings/index.md index d4bcdaa0..ac0d697d 100644 --- a/docs/src/keybindings/index.md +++ b/docs/src/keybindings/index.md @@ -30,6 +30,7 @@ The default key bindings can be overridden. | / | Start search | `search` | | Esc | Cancel search | `cancel` | | n/N | Go to next/previous search match | `go_to_next` `go_to_previous` | +| Ctrl-t | Toggle search target | `search_target_toggle` | | Ctrl-g | Toggle ignore case | `ignore_case_toggle` | | Ctrl-x | Toggle fuzzy match | `fuzzy_toggle` | | R | Refresh | `refresh` | diff --git a/src/app.rs b/src/app.rs index 7883c030..c767a42a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,6 +24,7 @@ use crate::{ graph::{CellWidthType, Graph, GraphImageManager}, keybind::KeyBind, protocol::ImageProtocol, + search::SearchOptions, view::{RefreshViewContext, View}, widget::commit_list::{CommitInfo, CommitListState}, }; @@ -127,8 +128,11 @@ impl<'a> App<'a> { graph_cell_width, head, ref_name_to_commit_index_map, - ctx.core_config.search.ignore_case, - ctx.core_config.search.fuzzy, + SearchOptions { + target: ctx.core_config.search.target, + ignore_case: ctx.core_config.search.ignore_case, + fuzzy: ctx.core_config.search.fuzzy, + }, ); if let InitialSelection::Head = initial_selection { match repository.head() { diff --git a/src/config.rs b/src/config.rs index 93887029..87f26446 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,7 @@ use crate::{ color::{ColorTheme, OptionalColorTheme}, graph::GraphImageWidthMode, keybind::KeyBind, + search::SearchTarget, CommitOrderType, GraphStyle, GraphWidthType, ImageProtocolType, InitialSelection, Result, }; @@ -142,6 +143,8 @@ pub struct CoreGitConfig { #[optional(derives = [Deserialize])] #[derive(Debug, Clone, PartialEq, Eq, SmartDefault)] pub struct CoreSearchConfig { + #[default(SearchTarget::All)] + pub target: SearchTarget, #[default = false] pub ignore_case: bool, #[default = false] @@ -449,6 +452,7 @@ mod tests { }, git: CoreGitConfig { mailmap: false }, search: CoreSearchConfig { + target: SearchTarget::All, ignore_case: false, fuzzy: false, }, @@ -535,6 +539,7 @@ mod tests { [core.git] mailmap = true [core.search] + target = "author" ignore_case = true fuzzy = true [core.user_command] @@ -579,6 +584,7 @@ mod tests { }, git: CoreGitConfig { mailmap: true }, search: CoreSearchConfig { + target: SearchTarget::Author, ignore_case: true, fuzzy: true, }, @@ -691,6 +697,7 @@ mod tests { }, git: CoreGitConfig { mailmap: false }, search: CoreSearchConfig { + target: SearchTarget::All, ignore_case: false, fuzzy: false, }, diff --git a/src/event.rs b/src/event.rs index 64216993..3786abe2 100644 --- a/src/event.rs +++ b/src/event.rs @@ -229,6 +229,7 @@ pub enum UserEvent { RefList, Search, UserCommand(usize), + SearchTargetToggle, IgnoreCaseToggle, FuzzyToggle, Refresh, @@ -292,6 +293,7 @@ impl<'de> Deserialize<'de> for UserEvent { "confirm" => Ok(UserEvent::Confirm), "ref_list" | "ref_list_toggle" => Ok(UserEvent::RefList), "search" => Ok(UserEvent::Search), + "search_target_toggle" => Ok(UserEvent::SearchTargetToggle), "ignore_case_toggle" => Ok(UserEvent::IgnoreCaseToggle), "fuzzy_toggle" => Ok(UserEvent::FuzzyToggle), "refresh" => Ok(UserEvent::Refresh), diff --git a/src/main.rs b/src/main.rs index 31e3d0d4..3e03c502 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod git; mod graph; mod keybind; mod protocol; +mod search; mod view; mod widget; diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 00000000..0efcde18 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,86 @@ +use serde::Deserialize; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SearchTarget { + #[default] + All, + Subject, + Author, + Ref, + Hash, +} + +impl SearchTarget { + pub fn next(self) -> Self { + match self { + Self::All => Self::Subject, + Self::Subject => Self::Author, + Self::Author => Self::Ref, + Self::Ref => Self::Hash, + Self::Hash => Self::All, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::All => "all", + Self::Subject => "subject", + Self::Author => "author", + Self::Ref => "ref", + Self::Hash => "hash", + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct SearchOptions { + pub target: SearchTarget, + pub ignore_case: bool, + pub fuzzy: bool, +} + +impl SearchOptions { + pub fn status_string(&self) -> String { + let case = if self.ignore_case { + "ignore-case" + } else { + "case-sensitive" + }; + let matcher = if self.fuzzy { "fuzzy" } else { "substring" }; + format!("[{}] [{case}] [{matcher}]", self.target.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_search_target_next_cycles_through_all_targets() { + let mut target = SearchTarget::All; + let expected = [ + SearchTarget::Subject, + SearchTarget::Author, + SearchTarget::Ref, + SearchTarget::Hash, + SearchTarget::All, + ]; + + for expected_target in expected { + target = target.next(); + assert_eq!(target, expected_target); + } + } + + #[test] + fn test_search_options_status_string() { + let options = SearchOptions { + target: SearchTarget::Author, + ignore_case: true, + fuzzy: true, + }; + + assert_eq!(options.status_string(), "[author] [ignore-case] [fuzzy]"); + } +} diff --git a/src/view/help.rs b/src/view/help.rs index 588810d2..e9e4a7a6 100644 --- a/src/view/help.rs +++ b/src/view/help.rs @@ -251,6 +251,7 @@ fn build_lines( (vec![UserEvent::Cancel], "Cancel search".into()), (vec![UserEvent::GoToNext], "Go to next search match".into()), (vec![UserEvent::GoToPrevious], "Go to previous search match".into()), + (vec![UserEvent::SearchTargetToggle], "Toggle search target".into()), (vec![UserEvent::IgnoreCaseToggle], "Toggle ignore case".into()), (vec![UserEvent::FuzzyToggle], "Toggle fuzzy match".into()), (vec![UserEvent::Refresh], "Refresh".into()), diff --git a/src/view/list.rs b/src/view/list.rs index f594ac63..b1a72dc2 100644 --- a/src/view/list.rs +++ b/src/view/list.rs @@ -44,6 +44,10 @@ impl<'a> ListView<'a> { self.as_mut_list_state().cancel_search(); self.clear_search_query(); } + UserEvent::SearchTargetToggle => { + self.as_mut_list_state().toggle_search_target(); + self.update_search_status(); + } UserEvent::IgnoreCaseToggle => { self.as_mut_list_state().toggle_ignore_case(); self.update_search_status(); @@ -133,6 +137,10 @@ impl<'a> ListView<'a> { self.as_mut_list_state().start_search(); self.update_search_status(); } + UserEvent::SearchTargetToggle => { + self.as_mut_list_state().toggle_search_target(); + self.update_search_options_message(); + } UserEvent::IgnoreCaseToggle => { self.as_mut_list_state().toggle_ignore_case(); self.update_search_options_message(); diff --git a/src/view/views.rs b/src/view/views.rs index 6e2add65..e6bc0239 100644 --- a/src/view/views.rs +++ b/src/view/views.rs @@ -6,11 +6,12 @@ use crate::{ app::AppContext, event::{Sender, UserEventWithCount}, git::{Commit, FileChange, Ref}, + search::SearchOptions, view::{ detail::DetailView, help::HelpView, list::ListView, refs::RefsView, user_command::UserCommandView, }, - widget::commit_list::{CommitListState, SearchOptions, SearchRefreshContext}, + widget::commit_list::{CommitListState, SearchRefreshContext}, }; #[derive(Debug, Default)] diff --git a/src/widget/commit_list.rs b/src/widget/commit_list.rs index 64787d43..b67aaf29 100644 --- a/src/widget/commit_list.rs +++ b/src/widget/commit_list.rs @@ -21,6 +21,7 @@ use crate::{ git::{Commit, CommitHash, Head, Ref}, graph::GraphImageManager, protocol::PreparedImage, + search::{SearchOptions, SearchTarget}, }; static FUZZY_MATCHER: Lazy = Lazy::new(|| SkimMatcherV2::default().respect_case()); @@ -57,24 +58,6 @@ pub enum SearchState { }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SearchOptions { - pub ignore_case: bool, - pub fuzzy: bool, -} - -impl SearchOptions { - pub fn status_string(&self) -> String { - let case = if self.ignore_case { - "ignore-case" - } else { - "case-sensitive" - }; - let matcher = if self.fuzzy { "fuzzy" } else { "substring" }; - format!("[{case}] [{matcher}]") - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct SearchRefreshContext { query: String, @@ -100,19 +83,34 @@ struct SearchMatch { } impl SearchMatch { - fn set(&mut self, c: &Commit, refs: &[&Ref], matcher: &SearchMatcher) { - self.refs = refs - .iter() - .filter(|r| !matches!(*r, Ref::Stash { .. })) - .filter_map(|r| { - matcher - .matched_position(r.name()) - .map(|pos| (r.name().into(), pos)) - }) - .collect(); - self.subject = matcher.matched_position(&c.subject); - self.author_name = matcher.matched_position(&c.author_name); - self.commit_hash = matcher.matched_position(c.commit_hash.as_short_hash()); + fn set(&mut self, c: &Commit, refs: &[&Ref], matcher: &SearchMatcher, target: SearchTarget) { + self.refs = if matches!(target, SearchTarget::All | SearchTarget::Ref) { + refs.iter() + .filter(|r| !matches!(*r, Ref::Stash { .. })) + .filter_map(|r| { + matcher + .matched_position(r.name()) + .map(|pos| (r.name().into(), pos)) + }) + .collect() + } else { + FxHashMap::default() + }; + self.subject = if matches!(target, SearchTarget::All | SearchTarget::Subject) { + matcher.matched_position(&c.subject) + } else { + None + }; + self.author_name = if matches!(target, SearchTarget::All | SearchTarget::Author) { + matcher.matched_position(&c.author_name) + } else { + None + }; + self.commit_hash = if matches!(target, SearchTarget::All | SearchTarget::Hash) { + matcher.matched_position(c.commit_hash.as_short_hash()) + } else { + None + }; self.match_index = 0; } @@ -213,8 +211,7 @@ impl<'a> CommitListState<'a> { graph_cell_width: u16, head: &'a Head, ref_name_to_commit_index_map: FxHashMap<&'a str, usize>, - default_ignore_case: bool, - default_fuzzy: bool, + search_options: SearchOptions, ) -> CommitListState<'a> { let total = commits.len(); let commit_hash_set = commits.iter().map(|c| &c.commit.commit_hash).collect(); @@ -226,10 +223,7 @@ impl<'a> CommitListState<'a> { head, ref_name_to_commit_index_map, search_state: SearchState::Inactive, - search_options: SearchOptions { - ignore_case: default_ignore_case, - fuzzy: default_fuzzy, - }, + search_options, search_input: Input::default(), search_matches: vec![SearchMatch::default(); total], selected: 0, @@ -567,6 +561,11 @@ impl<'a> CommitListState<'a> { self.update_search_after_options_change(); } + pub fn toggle_search_target(&mut self) { + self.search_options.target = self.search_options.target.next(); + self.update_search_after_options_change(); + } + pub fn search_query_string(&self) -> Option { if let SearchState::Searching { .. } = self.search_state { let query = self.search_input.value(); @@ -609,7 +608,12 @@ impl<'a> CommitListState<'a> { let mut match_index = 1; for (i, commit_info) in self.commits.iter().enumerate() { let m = &mut self.search_matches[i]; - m.set(commit_info.commit, commit_info.refs.as_slice(), &matcher); + m.set( + commit_info.commit, + commit_info.refs.as_slice(), + &matcher, + self.search_options.target, + ); if m.matched() { m.match_index = match_index; match_index += 1; @@ -1217,8 +1221,7 @@ mod tests { 0, repository.head(), FxHashMap::default(), - false, - false, + SearchOptions::default(), ); state.reset_height(subjects.len()); f(&mut state) @@ -1249,6 +1252,7 @@ mod tests { assert_eq!( options, SearchOptions { + target: SearchTarget::All, ignore_case: true, fuzzy: true, } @@ -1367,22 +1371,112 @@ mod tests { state.toggle_ignore_case(); assert_eq!( state.search_options().status_string(), - "[ignore-case] [substring]" + "[all] [ignore-case] [substring]" ); state.toggle_ignore_case(); assert_eq!( state.search_options().status_string(), - "[case-sensitive] [substring]" + "[all] [case-sensitive] [substring]" ); state.toggle_fuzzy(); assert_eq!( state.search_options().status_string(), - "[case-sensitive] [fuzzy]" + "[all] [case-sensitive] [fuzzy]" ); state.toggle_fuzzy(); assert_eq!( state.search_options().status_string(), - "[case-sensitive] [substring]" + "[all] [case-sensitive] [substring]" + ); + state.toggle_search_target(); + assert_eq!( + state.search_options().status_string(), + "[subject] [case-sensitive] [substring]" + ); + }); + } + + #[test] + fn test_search_target_matches_only_the_selected_field() { + let commit = Commit { + commit_hash: CommitHash::from("abcdef0123456789abcdef0123456789abcdef01"), + subject: "subject-match".into(), + author_name: "author-match".into(), + ..Commit::default() + }; + let reference = Ref::Branch { + name: "ref-match".into(), + target: commit.commit_hash.clone(), + }; + let refs = [&reference]; + let cases = [ + (SearchTarget::All, "subject-match", true), + (SearchTarget::All, "author-match", true), + (SearchTarget::All, "ref-match", true), + (SearchTarget::All, "abcdef0", true), + (SearchTarget::Subject, "subject-match", true), + (SearchTarget::Subject, "author-match", false), + (SearchTarget::Author, "author-match", true), + (SearchTarget::Author, "ref-match", false), + (SearchTarget::Ref, "ref-match", true), + (SearchTarget::Ref, "abcdef0", false), + (SearchTarget::Hash, "abcdef0", true), + (SearchTarget::Hash, "subject-match", false), + ]; + + for (target, query, expected) in cases { + let matcher = SearchMatcher::new(query, false, false); + let mut search_match = SearchMatch::default(); + search_match.set(&commit, &refs, &matcher, target); + assert_eq!(search_match.matched(), expected, "{target:?}: {query}"); + } + } + + #[test] + fn test_search_target_change_clears_previous_field_matches() { + let commit = Commit { + commit_hash: CommitHash::from("abcdef0123456789abcdef0123456789abcdef01"), + subject: "match".into(), + author_name: "match".into(), + ..Commit::default() + }; + let reference = Ref::Branch { + name: "match".into(), + target: commit.commit_hash.clone(), + }; + let refs = [&reference]; + let matcher = SearchMatcher::new("match", false, false); + let mut search_match = SearchMatch::default(); + + search_match.set(&commit, &refs, &matcher, SearchTarget::All); + assert!(!search_match.refs.is_empty()); + assert!(search_match.subject.is_some()); + assert!(search_match.author_name.is_some()); + + search_match.set(&commit, &refs, &matcher, SearchTarget::Hash); + assert!(search_match.refs.is_empty()); + assert!(search_match.subject.is_none()); + assert!(search_match.author_name.is_none()); + } + + #[test] + fn test_applied_search_target_toggle_recalculates_matches() { + with_commit_list_state(&["fix", "other"], |state| { + input_search_query(state, "fix"); + state.apply_search(); + + state.toggle_search_target(); + assert_eq!(state.search_options().target, SearchTarget::Subject); + assert_eq!( + state.matched_query_string(), + Some(("Match 1 of 1 (query: \"fix\")".into(), true)) + ); + + state.toggle_search_target(); + assert_eq!(state.search_options().target, SearchTarget::Author); + assert_eq!( + state.matched_query_string(), + Some(("No matches found (query: \"fix\")".into(), false)) ); }); }