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))
);
});
}