diff --git a/CHANGELOG.md b/CHANGELOG.md index b17b40c18d..3bac67b4bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * support 'n'/'p' key to move to the next/prev hunk in diff component [[@hamflx](https://github.com/hamflx)] ([#1523](https://github.com/extrawurst/gitui/issues/1523)) * simplify theme overrides [[@cruessler](https://github.com/cruessler)] ([#1367](https://github.com/extrawurst/gitui/issues/1367)) +* allow searching for commits in the revlog tab by sha, author, message or tag [[@WizardOhio24](https://github.com/WizardOhio24), [@willir](https://github.com/willir), [@Rodrigodd](https://github.com/Rodrigodd)] [#1753](https://github.com/extrawurst/gitui/issues/1753) ### Fixes * fix commit dialog char count for multibyte characters ([#1726](https://github.com/extrawurst/gitui/issues/1726)) diff --git a/README.md b/README.md index bdde5f10af..53fbffbb8f 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ - Stashing (save, pop, apply, drop, and inspect) - Push/Fetch to/from remote - Branch List (create, rename, delete, checkout, remotes) -- Browse commit log, diff committed changes +- Browse commit log, diff committed changes, search/filter commits - Scalable terminal UI layout - Async git API for fluid control - Submodule support diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index 18621436b8..4a36b5d35a 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -26,6 +26,7 @@ pub enum FetchStatus { } /// +#[derive(Clone)] pub struct AsyncLog { current: Arc>>, current_head: Arc>>, diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index b71ac3f177..d6791c32d7 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -22,6 +22,17 @@ impl CommitId { Self(id) } + /// Parse a hex-formatted object id into an Oid structure. + /// + /// # Errors + /// + /// Returns an error if the string is empty, is longer than 40 hex + /// characters, or contains any non-hex characters. + pub fn from_hex_str(id: &str) -> Result { + let oid = Oid::from_str(id)?; + Ok(Self::new(oid)) + } + /// pub(crate) const fn get_oid(self) -> Oid { self.0 @@ -52,7 +63,7 @@ impl From for CommitId { } /// -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CommitInfo { /// pub message: String, diff --git a/asyncgit/src/tags.rs b/asyncgit/src/tags.rs index d1ec08d112..3e57159e84 100644 --- a/asyncgit/src/tags.rs +++ b/asyncgit/src/tags.rs @@ -20,8 +20,9 @@ pub struct TagsResult { } /// +#[derive(Clone)] pub struct AsyncTags { - last: Option<(Instant, TagsResult)>, + last: Arc>>, sender: Sender, job: AsyncSingleJob, repo: RepoPath, @@ -35,7 +36,7 @@ impl AsyncTags { ) -> Self { Self { repo, - last: None, + last: Arc::new(Mutex::new(None)), sender: sender.clone(), job: AsyncSingleJob::new(sender.clone()), } @@ -43,7 +44,11 @@ impl AsyncTags { /// last fetched result pub fn last(&self) -> Result> { - Ok(self.last.as_ref().map(|result| result.1.tags.clone())) + Ok(self + .last + .lock()? + .as_ref() + .map(|result| result.1.tags.clone())) } /// @@ -52,10 +57,12 @@ impl AsyncTags { } /// - fn is_outdated(&self, dur: Duration) -> bool { - self.last + fn is_outdated(&self, dur: Duration) -> Result { + Ok(self + .last + .lock()? .as_ref() - .map_or(true, |(last_time, _)| last_time.elapsed() > dur) + .map_or(true, |(last_time, _)| last_time.elapsed() > dur)) } /// @@ -70,7 +77,7 @@ impl AsyncTags { return Ok(()); } - let outdated = self.is_outdated(dur); + let outdated = self.is_outdated(dur)?; if !force && !outdated { return Ok(()); @@ -81,6 +88,7 @@ impl AsyncTags { if outdated { self.job.spawn(AsyncTagsJob::new( self.last + .lock()? .as_ref() .map_or(0, |(_, result)| result.hash), repo, @@ -88,7 +96,7 @@ impl AsyncTags { if let Some(job) = self.job.take_last() { if let Some(Ok(result)) = job.result() { - self.last = Some(result); + *self.last.lock()? = Some(result); } } } else { diff --git a/src/app.rs b/src/app.rs index 1811b508c7..6c94b56f88 100644 --- a/src/app.rs +++ b/src/app.rs @@ -392,7 +392,7 @@ impl App { log::trace!("event: {:?}", ev); if let InputEvent::Input(ev) = ev { - if self.check_hard_exit(&ev) || self.check_quit(&ev) { + if self.check_hard_exit(&ev) { return Ok(()); } @@ -445,6 +445,9 @@ impl App { ) { self.options_popup.show()?; NeedsUpdate::ALL + } else if key_match(k, self.key_config.keys.quit) { + self.do_quit = QuitState::Close; + NeedsUpdate::empty() } else { NeedsUpdate::empty() }; @@ -603,8 +606,8 @@ impl App { tags_popup, reset_popup, options_popup, - help, revlog, + help, status_tab, files_tab, stashing_tab, @@ -642,19 +645,6 @@ impl App { ] ); - fn check_quit(&mut self, ev: &Event) -> bool { - if self.any_popup_visible() { - return false; - } - if let Event::Key(e) = ev { - if key_match(e, self.key_config.keys.quit) { - self.do_quit = QuitState::Close; - return true; - } - } - false - } - fn check_hard_exit(&mut self, ev: &Event) -> bool { if let Event::Key(e) = ev { if key_match(e, self.key_config.keys.exit) { @@ -887,6 +877,9 @@ impl App { self.push_tags_popup.push_tags()?; flags.insert(NeedsUpdate::ALL); } + InternalEvent::FilterLog(string_to_filter_by) => { + self.revlog.filter(&string_to_filter_by)?; + } InternalEvent::StatusLastFileMoved => { self.status_tab.last_file_moved()?; } diff --git a/src/components/find_commit.rs b/src/components/find_commit.rs new file mode 100644 index 0000000000..93891d270d --- /dev/null +++ b/src/components/find_commit.rs @@ -0,0 +1,117 @@ +use super::{ + textinput::TextInputComponent, CommandBlocking, CommandInfo, + Component, DrawableComponent, EventState, +}; +use crate::{ + keys::{key_match, SharedKeyConfig}, + queue::{InternalEvent, Queue}, + strings, + ui::style::SharedTheme, +}; +use anyhow::Result; +use crossterm::event::Event; +use ratatui::{backend::Backend, layout::Rect, Frame}; + +pub struct FindCommitComponent { + input: TextInputComponent, + queue: Queue, + is_focused: bool, + visible: bool, + key_config: SharedKeyConfig, +} + +impl DrawableComponent for FindCommitComponent { + fn draw( + &self, + f: &mut Frame, + rect: Rect, + ) -> Result<()> { + self.input.draw(f, rect)?; + Ok(()) + } +} + +impl Component for FindCommitComponent { + fn commands( + &self, + _out: &mut Vec, + _force_all: bool, + ) -> CommandBlocking { + CommandBlocking::PassingOn + } + + fn event(&mut self, ev: &Event) -> Result { + if self.is_visible() && self.focused() { + if let Event::Key(e) = ev { + if key_match(e, self.key_config.keys.exit_popup) { + // Prevent text input closing + self.focus(false); + self.visible = false; + return Ok(EventState::Consumed); + } + } + if self.input.event(ev)?.is_consumed() { + self.queue.push(InternalEvent::FilterLog( + self.input.get_text().to_string(), + )); + return Ok(EventState::Consumed); + } + } + Ok(EventState::NotConsumed) + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn hide(&mut self) { + self.visible = false; + } + fn show(&mut self) -> Result<()> { + self.visible = true; + Ok(()) + } + + fn focus(&mut self, focus: bool) { + self.is_focused = focus; + } + + fn focused(&self) -> bool { + self.is_focused + } + + fn toggle_visible(&mut self) -> Result<()> { + self.visible = !self.visible; + Ok(()) + } +} + +impl FindCommitComponent { + /// + pub fn new( + queue: Queue, + theme: SharedTheme, + key_config: SharedKeyConfig, + ) -> Self { + let mut input_component = TextInputComponent::new( + theme, + key_config.clone(), + &strings::find_commit_title(&key_config), + &strings::find_commit_msg(&key_config), + false, + ); + input_component.show().expect("Will not error"); + input_component.embed(); + Self { + queue, + input: input_component, + key_config, + visible: false, + is_focused: false, + } + } + + pub fn clear_input(&mut self) { + self.input.clear(); + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 14498adcbb..95cfcb176a 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -12,6 +12,7 @@ mod diff; mod externaleditor; mod fetch; mod file_revlog; +mod find_commit; mod fuzzy_find_popup; mod help; mod inspect_commit; @@ -48,6 +49,7 @@ pub use diff::DiffComponent; pub use externaleditor::ExternalEditorComponent; pub use fetch::FetchComponent; pub use file_revlog::{FileRevOpen, FileRevlogComponent}; +pub use find_commit::FindCommitComponent; pub use fuzzy_find_popup::FuzzyFindPopup; pub use help::HelpComponent; pub use inspect_commit::{InspectCommitComponent, InspectCommitOpen}; @@ -67,6 +69,7 @@ pub use syntax_text::SyntaxTextComponent; pub use tag_commit::TagCommitComponent; pub use taglist::TagListComponent; pub use textinput::{InputType, TextInputComponent}; +pub use utils::async_commit_filter; pub use utils::filetree::FileTreeItemKind; use crate::ui::style::Theme; diff --git a/src/components/utils/async_commit_filter.rs b/src/components/utils/async_commit_filter.rs new file mode 100644 index 0000000000..8567d5db1c --- /dev/null +++ b/src/components/utils/async_commit_filter.rs @@ -0,0 +1,574 @@ +use anyhow::{Error, Result}; +use asyncgit::{ + sync::{self, CommitInfo, RepoPathRef, Tags}, + AsyncGitNotification, AsyncLog, AsyncTags, +}; +use bitflags::bitflags; +use crossbeam_channel::Sender; +use std::{borrow::Cow, convert::TryFrom, marker::PhantomData}; +use std::{ + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, + }, + thread, + time::Duration, +}; +use unicode_truncate::UnicodeTruncateStr; + +const FILTER_SLEEP_DURATION: Duration = Duration::from_millis(10); +const SLICE_SIZE: usize = 1200; + +bitflags! { + pub struct FilterBy: u32 { + const SHA = 0b0000_0001; + const AUTHOR = 0b0000_0010; + const MESSAGE = 0b0000_0100; + const NOT = 0b0000_1000; + const CASE_SENSITIVE = 0b0001_0000; + const TAGS = 0b0010_0000; + } +} + +impl FilterBy { + pub fn everywhere() -> Self { + Self::all() & !Self::NOT & !Self::CASE_SENSITIVE + } + + pub fn exclude_modifiers(self) -> Self { + self & !Self::NOT & !Self::CASE_SENSITIVE + } +} + +impl Default for FilterBy { + fn default() -> Self { + Self::all() & !Self::NOT & !Self::CASE_SENSITIVE + } +} + +impl TryFrom for FilterBy { + type Error = anyhow::Error; + + fn try_from(v: char) -> Result { + match v { + 's' => Ok(Self::SHA), + 'a' => Ok(Self::AUTHOR), + 'm' => Ok(Self::MESSAGE), + '!' => Ok(Self::NOT), + 'c' => Ok(Self::CASE_SENSITIVE), + 't' => Ok(Self::TAGS), + _ => Err(anyhow::anyhow!("Unknown flag: {v}")), + } + } +} + +pub struct AsyncCommitFilterer { + repo: RepoPathRef, + git_log: AsyncLog, + git_tags: AsyncTags, + filtered_commits: Arc>>, + filter_count: Arc, + /// True if the filter thread is currently not running. + filter_finished: Arc, + /// Tells the last filter thread to stop early when set to true. + filter_stop_signal: Arc, + filter_thread_mutex: Arc>, + sender: Sender, + + /// `start_filter` logic relies on it being non-reentrant. + _non_sync: PhantomData>, +} + +impl AsyncCommitFilterer { + pub fn new( + repo: RepoPathRef, + git_log: AsyncLog, + git_tags: AsyncTags, + sender: &Sender, + ) -> Self { + Self { + repo, + git_log, + git_tags, + filtered_commits: Arc::new(Mutex::new(Vec::new())), + filter_count: Arc::new(AtomicUsize::new(0)), + filter_finished: Arc::new(AtomicBool::new(true)), + filter_thread_mutex: Arc::new(Mutex::new(())), + filter_stop_signal: Arc::new(AtomicBool::new(false)), + sender: sender.clone(), + _non_sync: PhantomData, + } + } + + pub fn is_pending(&self) -> bool { + !self.filter_finished.load(Ordering::Relaxed) + } + + /// `filter_strings` should be split by or them and, for example, + /// + /// A || B && C && D || E + /// + /// would be + /// + /// vec [vec![A], vec![B, C, D], vec![E]] + #[allow(clippy::too_many_lines)] + pub fn filter( + vec_commit_info: Vec, + tags: &Option, + filter_strings: &[Vec<(String, FilterBy)>], + ) -> Vec { + vec_commit_info + .into_iter() + .filter(|commit| { + Self::filter_one(filter_strings, tags, commit) + }) + .collect() + } + + fn filter_one( + filter_strings: &[Vec<(String, FilterBy)>], + tags: &Option, + commit: &CommitInfo, + ) -> bool { + for to_and in filter_strings { + if Self::filter_and(to_and, tags, commit) { + return true; + } + } + false + } + + fn filter_and( + to_and: &Vec<(String, FilterBy)>, + tags: &Option, + commit: &CommitInfo, + ) -> bool { + for (s, filter) in to_and { + let by_sha = filter.contains(FilterBy::SHA); + let by_aut = filter.contains(FilterBy::AUTHOR); + let by_mes = filter.contains(FilterBy::MESSAGE); + let by_tag = filter.contains(FilterBy::TAGS); + + let id: String; + let author: Cow; + let message: Cow; + if filter.contains(FilterBy::CASE_SENSITIVE) { + id = commit.id.to_string(); + author = Cow::Borrowed(&commit.author); + message = Cow::Borrowed(&commit.message); + } else { + id = commit.id.to_string().to_lowercase(); + author = Cow::Owned(commit.author.to_lowercase()); + message = Cow::Owned(commit.message.to_lowercase()); + }; + + let is_match = { + let tag_contains = tags.as_ref().map_or(false, |t| { + t.get(&commit.id).map_or(false, |commit_tags| { + commit_tags + .iter() + .any(|tag| tag.name.contains(s)) + }) + }); + + (by_tag && tag_contains) + || (by_sha && id.contains(s)) + || (by_aut && author.contains(s)) + || (by_mes && message.contains(s)) + }; + + let is_match = if filter.contains(FilterBy::NOT) { + !is_match + } else { + is_match + }; + + if !is_match { + return false; + } + } + true + } + + /// Check if the filtering string contain filtering by tags. + fn contains_tag( + filter_strings: &[Vec<(String, FilterBy)>], + ) -> bool { + let mut contains_tags = false; + for or in filter_strings { + for (_, filter_by) in or { + if filter_by.contains(FilterBy::TAGS) { + contains_tags = true; + break; + } + } + if contains_tags { + break; + } + } + + contains_tags + } + + pub fn start_filter( + &mut self, + filter_strings: Vec>, + ) -> Result<()> { + self.stop_filter(); + + // `stop_filter` blocks until the previous threads finish, and + // Self is !Sync, so two threads cannot be spawn at the same + // time. + // + // We rely on these assumptions to keep `filtered_commits` + // consistent. + + let filtered_commits = Arc::clone(&self.filtered_commits); + + filtered_commits.lock().expect("mutex poisoned").clear(); + + let filter_count = Arc::clone(&self.filter_count); + let async_log = self.git_log.clone(); + let filter_finished = Arc::clone(&self.filter_finished); + + self.filter_stop_signal = Arc::new(AtomicBool::new(false)); + let filter_stop_signal = Arc::clone(&self.filter_stop_signal); + + let async_app_sender = self.sender.clone(); + + let filter_thread_mutex = + Arc::clone(&self.filter_thread_mutex); + + let tags = Self::contains_tag(&filter_strings) + .then(|| { + self.git_tags.last().map_err(|e| anyhow::anyhow!(e)) + }) + .transpose()? + .flatten(); + + let repo = self.repo.clone(); + + #[allow(clippy::significant_drop_tightening)] + rayon_core::spawn(move || { + // Only 1 thread can filter at a time + let _c = + filter_thread_mutex.lock().expect("mutex poisoned"); + + filter_finished.store(false, Ordering::Relaxed); + filter_count.store(0, Ordering::Relaxed); + let mut cur_index: usize = 0; + let result = loop { + if filter_stop_signal.load(Ordering::Relaxed) { + break Ok(()); + } + + // Get the git_log and start filtering through it + let ids = match async_log + .get_slice(cur_index, SLICE_SIZE) + { + Ok(ids) => ids, + // Only errors if the lock is poisoned + Err(err) => break Err(err), + }; + + let v = match sync::get_commits_info( + &repo.borrow(), + &ids, + usize::MAX, + ) { + Ok(v) => v, + // May error while querying the repo or commits + Err(err) => break Err(err), + }; + + // Assume finished if log not pending and 0 recieved + if v.is_empty() && !async_log.is_pending() { + break Ok(()); + } + + let mut filtered = + Self::filter(v, &tags, &filter_strings); + filter_count + .fetch_add(filtered.len(), Ordering::Relaxed); + + filtered_commits + .lock() + .expect("mutex poisoned") + .append(&mut filtered); + + cur_index += SLICE_SIZE; + async_app_sender + .send(AsyncGitNotification::Log) + .expect("error sending"); + + thread::sleep(FILTER_SLEEP_DURATION); + }; + + filter_finished.store(true, Ordering::Relaxed); + + if let Err(e) = result { + log::error!("async job error: {}", e); + } + }); + Ok(()) + } + + /// Stop the filter thread if one was running, otherwise does nothing. This blocks until the + /// filter thread is finished. + pub fn stop_filter(&self) { + self.filter_stop_signal.store(true, Ordering::Relaxed); + + // wait for the filter thread to finish + drop(self.filter_thread_mutex.lock()); + } + + pub fn get_filter_items( + &mut self, + start: usize, + amount: usize, + message_length_limit: usize, + ) -> Result> { + let mut commits_requested = { + let fc = self + .filtered_commits + .lock() + .map_err(|_| Error::msg("mutex poisoned"))?; + let len = fc.len(); + let min = start.min(len); + let max = min + amount; + let max = max.min(len); + + fc[min..max].to_vec() + }; + + for c in &mut commits_requested { + c.message = c + .message + .unicode_truncate(message_length_limit) + .0 + .to_owned(); + } + Ok(commits_requested) + } + + pub fn count(&self) -> usize { + self.filter_count.load(Ordering::Relaxed) + } +} + +#[cfg(test)] +mod test { + use std::collections::BTreeMap; + + use asyncgit::sync::{CommitId, CommitInfo, Tag, Tags}; + + use crate::tabs::Revlog; + + use super::AsyncCommitFilterer; + + fn commit( + time: i64, + message: &str, + author: &str, + id: &str, + ) -> CommitInfo { + CommitInfo { + message: message.to_string(), + time, + author: author.to_string(), + id: CommitId::from_hex_str(id) + .expect("invalid commit id"), + } + } + + fn filter( + commits: Vec, + filter: &str, + ) -> Vec { + let filter_string = Revlog::get_what_to_filter_by(filter); + dbg!(&filter_string); + AsyncCommitFilterer::filter(commits, &None, &filter_string) + } + + #[test] + fn test_filter() { + let commits = vec![ + commit(0, "a", "b", "00"), + commit(1, "0", "0", "a1"), + commit(2, "0", "A", "b2"), + commit(3, "0", "0", "03"), + ]; + + let filtered = |indices: &[usize]| { + indices + .iter() + .map(|i| commits[*i].clone()) + .collect::>() + }; + + assert_eq!( + filter(commits.clone(), "a"), // + filtered(&[0, 1, 2]) + ); + assert_eq!( + filter(commits.clone(), "A"), // + filtered(&[0, 1, 2]) + ); + + assert_eq!( + filter(commits.clone(), ":m a"), // + filtered(&[0]), + ); + assert_eq!( + filter(commits.clone(), ":s a"), // + filtered(&[1]), + ); + assert_eq!( + filter(commits.clone(), ":a a"), // + filtered(&[2]), + ); + + assert_eq!( + filter(commits.clone(), ":! a"), // + filtered(&[3]), + ); + + assert_eq!( + filter(commits.clone(), ":!m a"), // + filtered(&[1, 2, 3]), + ); + assert_eq!( + filter(commits.clone(), ":!s a"), // + filtered(&[0, 2, 3]), + ); + assert_eq!( + filter(commits.clone(), ":!a a"), // + filtered(&[0, 1, 3]), + ); + + assert_eq!( + filter(commits.clone(), "a && b"), // + filtered(&[0, 2]), + ); + + assert_eq!( + filter(commits.clone(), ":m a && :a b"), // + filtered(&[0]), + ); + assert_eq!( + filter(commits.clone(), "b && :!m a"), // + filtered(&[2]), + ); + assert_eq!( + filter(commits.clone(), ":! b && a"), // + filtered(&[1]), + ); + assert_eq!( + filter(commits.clone(), ":! b && :! a"), // + filtered(&[3]), + ); + + assert_eq!( + filter(commits.clone(), ":c a"), // + filtered(&[0, 1]), + ); + assert_eq!( + filter(commits.clone(), ":c A"), // + filtered(&[2]), + ); + assert_eq!( + filter(commits.clone(), ":!c a"), // + filtered(&[2, 3]), + ); + } + + fn filter_with_tags( + commits: Vec, + tags: &Option, + filter: &str, + ) -> Vec { + let filter_string = Revlog::get_what_to_filter_by(filter); + dbg!(&filter_string); + AsyncCommitFilterer::filter(commits, tags, &filter_string) + } + + #[test] + fn test_filter_with_tags() { + let commits = vec![ + commit(0, "a", "b", "00"), + commit(1, "0", "0", "a1"), + commit(2, "0", "A", "b2"), + commit(3, "0", "0", "03"), + ]; + + let filtered = |indices: &[usize]| { + indices + .iter() + .map(|i| commits[*i].clone()) + .collect::>() + }; + + let tags = { + let mut tags: BTreeMap> = + BTreeMap::new(); + let mut tag = |index: usize, name: &str, annotation| { + tags.entry(commits[index].id).or_default().push( + Tag { + name: name.to_string(), + annotation, + }, + ); + }; + + tag(0, "v0", None); + tag(2, "v1", None); + tag(1, "ot", None); + + tags + }; + dbg!(&tags); + let tags = &Some(tags); + + assert_eq!( + filter_with_tags(commits.clone(), tags, ":t v0"), // + filtered(&[0]) + ); + assert_eq!( + filter_with_tags(commits.clone(), tags, ":t v1"), // + filtered(&[2]) + ); + assert_eq!( + filter_with_tags(commits.clone(), tags, ":t v"), // + filtered(&[0, 2]) + ); + assert_eq!( + filter_with_tags(commits.clone(), tags, ":!t v"), // + filtered(&[1, 3]) + ); + + assert_eq!( + filter_with_tags(commits.clone(), tags, ":t"), // + filtered(&[0, 1, 2]) + ); + assert_eq!( + filter_with_tags(commits.clone(), tags, ":!t"), // + filtered(&[3]) + ); + } + + #[test] + fn test_contains_tag() { + assert!(AsyncCommitFilterer::contains_tag( + &Revlog::get_what_to_filter_by("") + )); + assert!(AsyncCommitFilterer::contains_tag( + &Revlog::get_what_to_filter_by(":") + )); + assert!(AsyncCommitFilterer::contains_tag( + &Revlog::get_what_to_filter_by(":t") + )); + assert!(!AsyncCommitFilterer::contains_tag( + &Revlog::get_what_to_filter_by(":sma") + )); + } +} diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index f70397b54c..d4291c7acc 100644 --- a/src/components/utils/mod.rs +++ b/src/components/utils/mod.rs @@ -1,6 +1,7 @@ use chrono::{DateTime, Local, NaiveDateTime, Utc}; use unicode_width::UnicodeWidthStr; +pub mod async_commit_filter; #[cfg(feature = "ghemoji")] pub mod emoji; pub mod filetree; diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 134d992d87..aa5acddc85 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -50,6 +50,7 @@ pub struct KeysList { pub exit_popup: GituiKeyEvent, pub open_commit: GituiKeyEvent, pub open_commit_editor: GituiKeyEvent, + pub show_find_commit_text_input: GituiKeyEvent, pub open_help: GituiKeyEvent, pub open_options: GituiKeyEvent, pub move_left: GituiKeyEvent, @@ -136,6 +137,7 @@ impl Default for KeysList { exit_popup: GituiKeyEvent::new(KeyCode::Esc, KeyModifiers::empty()), open_commit: GituiKeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty()), open_commit_editor: GituiKeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), + show_find_commit_text_input: GituiKeyEvent::new(KeyCode::Char('s'), KeyModifiers::empty()), open_help: GituiKeyEvent::new(KeyCode::Char('h'), KeyModifiers::empty()), open_options: GituiKeyEvent::new(KeyCode::Char('o'), KeyModifiers::empty()), move_left: GituiKeyEvent::new(KeyCode::Left, KeyModifiers::empty()), diff --git a/src/queue.rs b/src/queue.rs index c815ce6589..f5ccdeac20 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -109,6 +109,8 @@ pub enum InternalEvent { /// PushTags, /// + FilterLog(String), + /// OptionSwitched(AppOption), /// OpenFuzzyFinder(Vec, FuzzyFinderTarget), diff --git a/src/strings.rs b/src/strings.rs index 3e1ff80633..c182907c41 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -288,6 +288,12 @@ pub fn file_log_title( ) -> String { format!("Revisions of '{file_path}' ({selected}/{revisions})") } +pub fn find_commit_title(_key_config: &SharedKeyConfig) -> String { + "Find Commit".to_string() +} +pub fn find_commit_msg(_key_config: &SharedKeyConfig) -> String { + "Search Sha, Author and Message".to_string() +} pub fn blame_title(_key_config: &SharedKeyConfig) -> String { "Blame".to_string() } @@ -603,6 +609,19 @@ pub mod commands { CMD_GROUP_LOG, ) } + + pub fn find_commit(key_config: &SharedKeyConfig) -> CommandText { + CommandText::new( + format!( + "Find Commit [{}]", + key_config + .get_hint(key_config.keys.show_find_commit_text_input), + ), + "show find commit box to search by sha, author or message", + CMD_GROUP_LOG, + ) + } + pub fn diff_hunk_next( key_config: &SharedKeyConfig, ) -> CommandText { diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index 73ecef0dbb..e2b731fbde 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -1,9 +1,10 @@ use crate::{ components::{ + async_commit_filter::{AsyncCommitFilterer, FilterBy}, visibility_blocking, CommandBlocking, CommandInfo, CommitDetailsComponent, CommitList, Component, DrawableComponent, EventState, FileTreeOpen, - InspectCommitOpen, + FindCommitComponent, InspectCommitOpen, }, keys::{key_match, SharedKeyConfig}, queue::{InternalEvent, Queue, StackablePopupOpen}, @@ -34,6 +35,8 @@ pub struct Revlog { repo: RepoPathRef, commit_details: CommitDetailsComponent, list: CommitList, + find_commit: FindCommitComponent, + async_filter: AsyncCommitFilterer, git_log: AsyncLog, git_tags: AsyncTags, git_local_branches: AsyncSingleJob, @@ -41,6 +44,8 @@ pub struct Revlog { queue: Queue, visible: bool, key_config: SharedKeyConfig, + is_filtering: bool, + filter_string: String, } impl Revlog { @@ -52,6 +57,8 @@ impl Revlog { theme: SharedTheme, key_config: SharedKeyConfig, ) -> Self { + let log = AsyncLog::new(repo.borrow().clone(), sender, None); + let tags = AsyncTags::new(repo.borrow().clone(), sender); Self { repo: repo.clone(), queue: queue.clone(), @@ -65,20 +72,29 @@ impl Revlog { list: CommitList::new( repo.clone(), &strings::log_title(&key_config), - theme, + theme.clone(), + queue.clone(), + key_config.clone(), + ), + find_commit: FindCommitComponent::new( queue.clone(), + theme, key_config.clone(), ), - git_log: AsyncLog::new( - repo.borrow().clone(), + async_filter: AsyncCommitFilterer::new( + repo.clone(), + log.clone(), + tags.clone(), sender, - None, ), - git_tags: AsyncTags::new(repo.borrow().clone(), sender), + git_log: log, + git_tags: tags, git_local_branches: AsyncSingleJob::new(sender.clone()), git_remote_branches: AsyncSingleJob::new(sender.clone()), visible: false, key_config, + is_filtering: false, + filter_string: String::new(), } } @@ -86,6 +102,7 @@ impl Revlog { pub fn any_work_pending(&self) -> bool { self.git_log.is_pending() || self.git_tags.is_pending() + || self.async_filter.is_pending() || self.git_local_branches.is_pending() || self.git_remote_branches.is_pending() || self.commit_details.any_work_pending() @@ -94,10 +111,17 @@ impl Revlog { /// pub fn update(&mut self) -> Result<()> { if self.is_visible() { - let log_changed = - self.git_log.fetch()? == FetchStatus::Started; - - self.list.set_count_total(self.git_log.count()?); + let log_changed = if self.is_filtering { + false + } else { + self.git_log.fetch()? == FetchStatus::Started + }; + + if self.is_filtering { + self.list.set_count_total(self.async_filter.count()); + } else { + self.list.set_count_total(self.git_log.count()?); + }; let selection = self.list.selection(); let selection_max = self.list.selection_max(); @@ -174,14 +198,28 @@ impl Revlog { let want_min = self.list.selection().saturating_sub(SLICE_SIZE / 2); - let commits = sync::get_commits_info( - &self.repo.borrow(), - &self.git_log.get_slice(want_min, SLICE_SIZE)?, - self.list - .current_size() - .map_or(100u16, |size| size.0) - .into(), - ); + let commits = if self.is_filtering { + self.async_filter + .get_filter_items( + want_min, + SLICE_SIZE, + self.list + .current_size() + .map_or(100u16, |size| size.0) + .into(), + ) + .map_err(|e| anyhow::anyhow!(e.to_string())) + } else { + sync::get_commits_info( + &self.repo.borrow(), + &self.git_log.get_slice(want_min, SLICE_SIZE)?, + self.list + .current_size() + .map_or(100u16, |size| size.0) + .into(), + ) + .map_err(|e| anyhow::anyhow!(e.to_string())) + }; if let Ok(commits) = commits { self.list.items().set_items(want_min, commits); @@ -205,6 +243,84 @@ impl Revlog { }) } + /// Parses search string into individual sub-searches. + /// Each sub-search is a tuple of (string-to-search, flags-where-to-search) + /// + /// Returns vec of vec of sub-searches. + /// Where search results: + /// 1. from outer vec should be combined via 'disjunction' (or); + /// 2. from inter vec should be combined via 'conjunction' (and). + /// + /// Parentheses in the `filter_by_str` are not supported. + pub fn get_what_to_filter_by( + filter_by_str: &str, + ) -> Vec> { + let mut search_vec = Vec::new(); + let mut and_vec = Vec::new(); + for or in filter_by_str.split("||") { + for split_sub in or.split("&&").map(str::trim) { + if !split_sub.starts_with(':') { + and_vec.push(( + split_sub.to_lowercase(), + FilterBy::everywhere(), + )); + continue; + } + + let mut split_str = split_sub.splitn(2, ' '); + let first = split_str + .next() + .expect("Split must return at least one element"); + let mut to_filter_by = first.chars().skip(1).fold( + FilterBy::empty(), + |acc, ch| { + acc | FilterBy::try_from(ch) + .unwrap_or_else(|_| FilterBy::empty()) + }, + ); + + if to_filter_by.exclude_modifiers().is_empty() { + to_filter_by |= FilterBy::everywhere(); + } + + let mut s = split_str + .next() + .unwrap_or("") + .trim_start() + .to_string(); + + if !to_filter_by.contains(FilterBy::CASE_SENSITIVE) { + s = s.to_lowercase(); + } + + and_vec.push((s, to_filter_by)); + } + search_vec.push(and_vec.clone()); + and_vec.clear(); + } + search_vec + } + + pub fn filter(&mut self, filter_by: &str) -> Result<()> { + if filter_by != self.filter_string { + self.filter_string = filter_by.to_string(); + if filter_by.is_empty() { + self.async_filter.stop_filter(); + self.is_filtering = false; + } else { + let filter_strings = + Self::get_what_to_filter_by(filter_by); + self.async_filter + .start_filter(filter_strings) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + self.is_filtering = true; + } + self.list.clear(); + return self.update(); + } + Ok(()) + } + pub fn select_commit(&mut self, id: CommitId) -> Result<()> { let position = self.git_log.position(id)?; @@ -244,20 +360,48 @@ impl DrawableComponent for Revlog { f: &mut Frame, area: Rect, ) -> Result<()> { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Percentage(60), - Constraint::Percentage(40), - ] - .as_ref(), - ) - .split(area); - if self.commit_details.is_visible() { - self.list.draw(f, chunks[0])?; + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage(60), + Constraint::Percentage(40), + ] + .as_ref(), + ) + .split(area); + + if self.find_commit.is_visible() { + let log_find_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage(90), + Constraint::Percentage(20), + ] + .as_ref(), + ) + .split(chunks[0]); + self.list.draw(f, log_find_chunks[0])?; + self.find_commit.draw(f, log_find_chunks[1])?; + } else { + self.list.draw(f, chunks[0])?; + } self.commit_details.draw(f, chunks[1])?; + } else if self.find_commit.is_visible() { + let log_find_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage(90), + Constraint::Percentage(20), + ] + .as_ref(), + ) + .split(area); + self.list.draw(f, log_find_chunks[0])?; + self.find_commit.draw(f, log_find_chunks[1])?; } else { self.list.draw(f, area)?; } @@ -271,7 +415,10 @@ impl Component for Revlog { #[allow(clippy::too_many_lines)] fn event(&mut self, ev: &Event) -> Result { if self.visible { - let event_used = self.list.event(ev)?; + let mut event_used = self.find_commit.event(ev)?; + if !event_used.is_consumed() { + event_used = self.list.event(ev)?; + } if event_used.is_consumed() { self.update()?; @@ -316,6 +463,20 @@ impl Component for Revlog { ) { self.queue.push(InternalEvent::SelectBranch); return Ok(EventState::Consumed); + } else if key_match( + k, + self.key_config.keys.show_find_commit_text_input, + ) { + self.find_commit.toggle_visible()?; + self.find_commit.focus(true); + return Ok(EventState::Consumed); + } else if key_match( + k, + self.key_config.keys.exit_popup, + ) { + self.filter("")?; + self.find_commit.clear_input(); + self.update()?; } else if key_match( k, self.key_config.keys.status_reset_item, @@ -453,6 +614,12 @@ impl Component for Revlog { || force_all, )); + out.push(CommandInfo::new( + strings::commands::find_commit(&self.key_config), + true, + self.visible || force_all, + )); + out.push(CommandInfo::new( strings::commands::copy_hash(&self.key_config), self.selected_commit().is_some(), @@ -537,3 +704,181 @@ impl Component for Revlog { Ok(()) } } + +#[cfg(test)] +mod test { + use super::Revlog; + use crate::components::async_commit_filter::FilterBy; + + #[test] + fn test_get_what_to_filter_by_flags() { + assert_eq!( + Revlog::get_what_to_filter_by("foo"), + vec![vec![("foo".to_owned(), FilterBy::everywhere())]] + ); + + assert_eq!( + Revlog::get_what_to_filter_by("Foo"), + vec![vec![("foo".to_owned(), FilterBy::everywhere())]] + ); + + assert_eq!( + Revlog::get_what_to_filter_by(":s foo"), + vec![vec![("foo".to_owned(), FilterBy::SHA)]] + ); + + assert_eq!( + Revlog::get_what_to_filter_by(":sm foo"), + vec![vec![( + "foo".to_owned(), + FilterBy::SHA | FilterBy::MESSAGE + )]] + ); + + assert_eq!( + Revlog::get_what_to_filter_by(": Foo"), + vec![vec![("foo".to_owned(), FilterBy::everywhere())]] + ); + assert_eq!( + Revlog::get_what_to_filter_by(":c Foo"), + vec![vec![( + "Foo".to_owned(), + FilterBy::everywhere() | FilterBy::CASE_SENSITIVE + )]], + ); + + assert_eq!( + Revlog::get_what_to_filter_by(":samt foo"), + vec![vec![("foo".to_owned(), FilterBy::everywhere())]] + ); + + assert_eq!( + Revlog::get_what_to_filter_by(":!csamt foo"), + vec![vec![("foo".to_owned(), FilterBy::all())]] + ); + + assert_eq!( + Revlog::get_what_to_filter_by(":!c foo"), + vec![vec![("foo".to_owned(), FilterBy::all())]] + ); + + assert_eq!( + Revlog::get_what_to_filter_by(":! foo"), + vec![vec![( + "foo".to_owned(), + FilterBy::everywhere() | FilterBy::NOT + )]] + ); + + assert_eq!( + Revlog::get_what_to_filter_by(":c foo"), + vec![vec![( + "foo".to_owned(), + FilterBy::everywhere() | FilterBy::CASE_SENSITIVE + )]] + ); + + assert_eq!( + Revlog::get_what_to_filter_by(":!m foo"), + vec![vec![( + "foo".to_owned(), + FilterBy::MESSAGE | FilterBy::NOT + )]] + ); + } + + #[test] + fn test_get_what_to_filter_by_log_op() { + assert_eq!( + Revlog::get_what_to_filter_by("foo && bar"), + vec![vec![ + ("foo".to_owned(), FilterBy::everywhere()), + ("bar".to_owned(), FilterBy::everywhere()) + ]] + ); + + assert_eq!( + Revlog::get_what_to_filter_by("foo || bar"), + vec![ + vec![("foo".to_owned(), FilterBy::everywhere())], + vec![("bar".to_owned(), FilterBy::everywhere())] + ] + ); + + assert_eq!( + Revlog::get_what_to_filter_by("foo && bar || :m baz"), + vec![ + vec![ + ("foo".to_owned(), FilterBy::everywhere()), + ("bar".to_owned(), FilterBy::everywhere()) + ], + vec![("baz".to_owned(), FilterBy::MESSAGE)] + ] + ); + + assert_eq!( + Revlog::get_what_to_filter_by("foo || :m bar && baz"), + vec![ + vec![("foo".to_owned(), FilterBy::everywhere())], + vec![ + ("bar".to_owned(), FilterBy::MESSAGE), + ("baz".to_owned(), FilterBy::everywhere()) + ] + ] + ); + } + + #[test] + fn test_get_what_to_filter_by_spaces() { + assert_eq!( + Revlog::get_what_to_filter_by("foo&&bar"), + vec![vec![ + ("foo".to_owned(), FilterBy::everywhere()), + ("bar".to_owned(), FilterBy::everywhere()) + ]] + ); + assert_eq!( + Revlog::get_what_to_filter_by(" foo && bar "), + vec![vec![ + ("foo".to_owned(), FilterBy::everywhere()), + ("bar".to_owned(), FilterBy::everywhere()) + ]] + ); + + assert_eq!( + Revlog::get_what_to_filter_by(" foo bar baz "), + vec![vec![( + "foo bar baz".to_owned(), + FilterBy::everywhere() + )]] + ); + assert_eq!( + Revlog::get_what_to_filter_by(" :m foo bar baz "), + vec![vec![( + "foo bar baz".to_owned(), + FilterBy::MESSAGE + )]] + ); + assert_eq!( + Revlog::get_what_to_filter_by( + " :m foo bar baz && qwe t " + ), + vec![vec![ + ("foo bar baz".to_owned(), FilterBy::MESSAGE), + ("qwe t".to_owned(), FilterBy::everywhere()) + ]] + ); + } + + #[test] + fn test_get_what_to_filter_by_invalid_flags_ignored() { + assert_eq!( + Revlog::get_what_to_filter_by(":q foo"), + vec![vec![("foo".to_owned(), FilterBy::everywhere())]] + ); + assert_eq!( + Revlog::get_what_to_filter_by(":mq foo"), + vec![vec![("foo".to_owned(), FilterBy::MESSAGE)]] + ); + } +} diff --git a/vim_style_key_config.ron b/vim_style_key_config.ron index 25d4091df9..16d2d6b391 100644 --- a/vim_style_key_config.ron +++ b/vim_style_key_config.ron @@ -41,5 +41,8 @@ stash_open: Some(( code: Char('l'), modifiers: ( bits: 0,),)), + show_find_commit_text_input: Some(( code: Char('s'), modifiers: ( bits: 0,),)), + focus_find_commit: Some(( code: Char('j'), modifiers: ( bits: 3,),)), + abort_merge: Some(( code: Char('M'), modifiers: ( bits: 1,),)), )