diff --git a/README.md b/README.md index 2717c70a3e..bb21c3043c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,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 diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index 55495948fe..0e3f2e6369 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -27,6 +27,7 @@ pub enum FetchStatus { } /// +#[derive(Clone)] pub struct AsyncLog { current: Arc>>, sender: Sender, diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index c10587159d..0858eb3573 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -48,7 +48,7 @@ impl From for CommitId { } /// -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct CommitInfo { /// pub message: String, diff --git a/asyncgit/src/tags.rs b/asyncgit/src/tags.rs index 388a60b482..63c2133341 100644 --- a/asyncgit/src/tags.rs +++ b/asyncgit/src/tags.rs @@ -22,6 +22,7 @@ struct TagsResult { } /// +#[derive(Clone)] pub struct AsyncTags { last: Arc>>, sender: Sender, diff --git a/src/app.rs b/src/app.rs index f6bcf605bc..3c9cb548bc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -571,6 +571,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/commitlist.rs b/src/components/commitlist.rs index b765ff7507..0afd6197dd 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -84,8 +84,8 @@ impl CommitList { } /// - pub fn set_count_total(&mut self, total: usize) { - self.count_total = total; + pub fn set_total_count(&mut self, count: usize) { + self.count_total = count; self.selection = cmp::min(self.selection, self.selection_max()); } diff --git a/src/components/find_commit.rs b/src/components/find_commit.rs new file mode 100644 index 0000000000..3c2249ff59 --- /dev/null +++ b/src/components/find_commit.rs @@ -0,0 +1,119 @@ +use super::{ + textinput::TextInputComponent, CommandBlocking, CommandInfo, + Component, DrawableComponent, EventState, +}; +use crate::{ + keys::SharedKeyConfig, + queue::{InternalEvent, Queue}, + strings, + ui::style::SharedTheme, +}; +use anyhow::Result; +use crossterm::event::Event; +use tui::{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 e == self.key_config.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.borrow_mut().push_back( + 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.set_should_use_rect(true); + 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 fb8c88ebbd..ed6cc781ca 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -10,6 +10,7 @@ mod cred; mod diff; mod externaleditor; mod filetree; +mod find_commit; mod help; mod inspect_commit; mod msg; @@ -35,6 +36,7 @@ pub use create_branch::CreateBranchComponent; pub use diff::DiffComponent; pub use externaleditor::ExternalEditorComponent; pub use filetree::FileTreeComponent; +pub use find_commit::FindCommitComponent; pub use help::HelpComponent; pub use inspect_commit::InspectCommitComponent; pub use msg::MsgComponent; @@ -47,6 +49,7 @@ pub use revision_files::RevisionFilesComponent; pub use stashmsg::StashMsgComponent; pub use tag_commit::TagCommitComponent; 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/textinput.rs b/src/components/textinput.rs index 18ccd21e41..b5ef00e9c8 100644 --- a/src/components/textinput.rs +++ b/src/components/textinput.rs @@ -39,6 +39,7 @@ pub struct TextInputComponent { key_config: SharedKeyConfig, cursor_position: usize, input_type: InputType, + should_use_rect: bool, current_area: Cell, } @@ -61,6 +62,7 @@ impl TextInputComponent { default_msg: default_msg.to_string(), cursor_position: 0, input_type: InputType::Multiline, + should_use_rect: false, current_area: Cell::new(Rect::default()), } } @@ -241,6 +243,10 @@ impl TextInputComponent { f.render_widget(w, rect); } } + + pub fn set_should_use_rect(&mut self, b: bool) { + self.should_use_rect = b; + } } // merges last line of `txt` with first of `append` so we do not generate unneeded newlines @@ -267,7 +273,7 @@ impl DrawableComponent for TextInputComponent { fn draw( &self, f: &mut Frame, - _rect: Rect, + rect: Rect, ) -> Result<()> { if self.visible { let txt = if self.msg.is_empty() { @@ -279,16 +285,21 @@ impl DrawableComponent for TextInputComponent { self.get_draw_text() }; - let area = match self.input_type { - InputType::Multiline => { - let area = ui::centered_rect(60, 20, f.size()); - ui::rect_inside( - Size::new(10, 3), - f.size().into(), - area, - ) + let area = if self.should_use_rect { + rect + } else { + match self.input_type { + InputType::Multiline => { + let area = + ui::centered_rect(60, 20, f.size()); + ui::rect_inside( + Size::new(10, 3), + f.size().into(), + area, + ) + } + _ => ui::centered_rect_absolute(32, 3, f.size()), } - _ => ui::centered_rect_absolute(32, 3, f.size()), }; f.render_widget(Clear, area); diff --git a/src/components/utils/async_commit_filter.rs b/src/components/utils/async_commit_filter.rs new file mode 100644 index 0000000000..aaea4468f4 --- /dev/null +++ b/src/components/utils/async_commit_filter.rs @@ -0,0 +1,441 @@ +use anyhow::{Error, Result}; +use asyncgit::{ + sync::{self, CommitId, CommitInfo, Tags}, + AsyncLog, AsyncNotification, AsyncTags, CWD, +}; +use bitflags::bitflags; +use crossbeam_channel::{Sender, TryRecvError}; +use std::convert::TryFrom; +use std::{ + cell::RefCell, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, + }, + thread, + time::Duration, +}; +use unicode_truncate::UnicodeTruncateStr; + +const FILTER_SLEEP_DURATION: Duration = Duration::from_millis(10); +const FILTER_SLEEP_DURATION_FAILED_LOCK: Duration = + Duration::from_millis(500); +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!(format!("Unknown flag: {}", v))), + } + } +} + +#[derive(PartialEq)] +pub enum FilterStatus { + Filtering, + Finished, +} + +pub struct AsyncCommitFilterer { + git_log: AsyncLog, + git_tags: AsyncTags, + filtered_commits: Arc>>, + filter_count: Arc, + filter_finished: Arc, + is_pending_local: RefCell, + filter_thread_sender: Option>, + filter_thread_mutex: Arc>, + sender: Sender, +} + +impl AsyncCommitFilterer { + pub fn new( + git_log: AsyncLog, + git_tags: AsyncTags, + sender: &Sender, + ) -> Self { + Self { + 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(false)), + filter_thread_mutex: Arc::new(Mutex::new(())), + is_pending_local: RefCell::new(false), + filter_thread_sender: None, + sender: sender.clone(), + } + } + + pub fn is_pending(&self) -> bool { + let mut b = self.is_pending_local.borrow_mut(); + if *b { + *b = self.fetch() == FilterStatus::Filtering; + *b + } else { + false + } + } + + /// `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( + mut vec_commit_info: Vec, + tags: &Option< + std::collections::BTreeMap>, + >, + filter_strings: &[Vec<(String, FilterBy)>], + ) -> Vec { + vec_commit_info + .drain(..) + .filter(|commit| { + for to_and in filter_strings { + let mut is_and = true; + for (s, filter) in to_and { + if filter.contains(FilterBy::CASE_SENSITIVE) { + is_and = if filter.contains(FilterBy::NOT) + { + (filter + .contains(FilterBy::TAGS) + && tags.as_ref().map_or(false, |t| t.get(&commit.id).map_or(true, |commit_tags| commit_tags.iter().filter(|tag_string|{ + !tag_string.contains(s) + }).count() > 0))) + || (filter + .contains(FilterBy::SHA) + && !commit + .id + .to_string() + .contains(s)) + || (filter.contains( + FilterBy::AUTHOR, + ) && !commit + .author + .contains(s)) + || (filter.contains( + FilterBy::MESSAGE, + ) && !commit + .message + .contains(s)) + } else { + (filter + .contains(FilterBy::TAGS) + && tags.as_ref().map_or(false, |t| t.get(&commit.id).map_or(false, |commit_tags| commit_tags.iter().filter(|tag_string|{ + tag_string.contains(s) + }).count() > 0))) + || (filter + .contains(FilterBy::SHA) + && commit + .id + .to_string() + .contains(s)) + || (filter.contains( + FilterBy::AUTHOR, + ) && commit + .author + .contains(s)) + || (filter.contains( + FilterBy::MESSAGE, + ) && commit + .message + .contains(s)) + } + } else { + is_and = if filter.contains(FilterBy::NOT) + { + (filter + .contains(FilterBy::TAGS) + && tags.as_ref().map_or(false, |t| t.get(&commit.id).map_or(true, |commit_tags| commit_tags.iter().filter(|tag_string|{ + !tag_string.to_lowercase().contains(&s.to_lowercase()) + }).count() > 0))) + || (filter + .contains(FilterBy::SHA) + && !commit + .id + .to_string() + .to_lowercase() + .contains( + &s.to_lowercase(), + )) + || (filter.contains( + FilterBy::AUTHOR, + ) && !commit + .author + .to_lowercase() + .contains( + &s.to_lowercase(), + )) + || (filter.contains( + FilterBy::MESSAGE, + ) && !commit + .message + .to_lowercase() + .contains( + &s.to_lowercase(), + )) + } else { + (filter + .contains(FilterBy::TAGS) + && tags.as_ref().map_or(false, |t| t.get(&commit.id).map_or(false, |commit_tags| commit_tags.iter().filter(|tag_string|{ + tag_string.to_lowercase().contains(&s.to_lowercase()) + }).count() > 0))) + || (filter + .contains(FilterBy::SHA) + && commit + .id + .to_string() + .to_lowercase() + .contains( + &s.to_lowercase(), + )) + || (filter.contains( + FilterBy::AUTHOR, + ) && commit + .author + .to_lowercase() + .contains( + &s.to_lowercase(), + )) + || (filter.contains( + FilterBy::MESSAGE, + ) && commit + .message + .to_lowercase() + .contains( + &s.to_lowercase(), + )) + } + } + } + if is_and { + return true; + } + } + false + }) + .collect() + } + + /// If the filtering string contain filtering by tags + /// return them, else don't get the tags + fn get_tags( + filter_strings: &[Vec<(String, FilterBy)>], + git_tags: &mut AsyncTags, + ) -> Result> { + 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; + } + } + + if contains_tags { + return git_tags.last().map_err(|e| anyhow::anyhow!(e)); + } + Ok(None) + } + + pub fn start_filter( + &mut self, + filter_strings: Vec>, + ) -> Result<()> { + self.stop_filter(); + + let filtered_commits = Arc::clone(&self.filtered_commits); + let filter_count = Arc::clone(&self.filter_count); + let async_log = self.git_log.clone(); + let filter_finished = Arc::clone(&self.filter_finished); + + let (tx, rx) = crossbeam_channel::unbounded(); + + self.filter_thread_sender = Some(tx); + let async_app_sender = self.sender.clone(); + + let prev_thread_mutex = Arc::clone(&self.filter_thread_mutex); + self.filter_thread_mutex = Arc::new(Mutex::new(())); + + let cur_thread_mutex = Arc::clone(&self.filter_thread_mutex); + self.is_pending_local.replace(true); + + let tags = + Self::get_tags(&filter_strings, &mut self.git_tags)?; + + rayon_core::spawn(move || { + // Only 1 thread can filter at a time + let _c = cur_thread_mutex.lock().expect("mutex poisoned"); + let _p = + prev_thread_mutex.lock().expect("mutex poisoned"); + filter_finished.store(false, Ordering::Relaxed); + filter_count.store(0, Ordering::Relaxed); + filtered_commits.lock().expect("mutex poisoned").clear(); + let mut cur_index: usize = 0; + loop { + match rx.try_recv() { + Ok(_) | Err(TryRecvError::Disconnected) => { + break; + } + _ => { + // Get the git_log and start filtering through it + match async_log + .get_slice(cur_index, SLICE_SIZE) + { + Ok(ids) => { + match sync::get_commits_info( + CWD, + &ids, + usize::MAX, + ) { + Ok(v) => { + if v.is_empty() + && !async_log.is_pending() + { + // Assume finished if log not pending and 0 recieved + filter_finished.store( + true, + Ordering::Relaxed, + ); + break; + } + + let mut filtered = + Self::filter( + v, + &tags, + &filter_strings, + ); + filter_count.fetch_add( + filtered.len(), + Ordering::Relaxed, + ); + let mut fc = filtered_commits + .lock() + .expect("mutex poisoned"); + fc.append(&mut filtered); + drop(fc); + cur_index += SLICE_SIZE; + async_app_sender + .send(AsyncNotification::Log) + .expect("error sending"); + thread::sleep( + FILTER_SLEEP_DURATION, + ); + } + Err(_) => { + // Failed to get commit info + thread::sleep( + FILTER_SLEEP_DURATION_FAILED_LOCK, + ); + } + } + } + Err(_) => { + // Failed to get slice + thread::sleep( + FILTER_SLEEP_DURATION_FAILED_LOCK, + ); + } + } + } + } + } + }); + Ok(()) + } + + /// Stop the filter if one was running, otherwise does nothing. + /// Is it possible to restart from this stage by calling restart + pub fn stop_filter(&self) { + // Any error this gives can be safely ignored, + // it will send if reciever exists, otherwise does nothing + if let Some(sender) = &self.filter_thread_sender { + match sender.try_send(true) { + Ok(_) | Err(_) => {} + }; + } + self.is_pending_local.replace(false); + self.filter_finished.store(true, Ordering::Relaxed); + } + + pub fn get_filter_items( + &mut self, + start: usize, + amount: usize, + message_length_limit: usize, + ) -> Result> { + 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); + let mut commits_requested = 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) + } + + pub fn fetch(&self) -> FilterStatus { + if self.filter_finished.load(Ordering::Relaxed) { + FilterStatus::Finished + } else { + FilterStatus::Filtering + } + } +} diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index c322035f57..fa37f474a8 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; pub mod filetree; pub mod logitems; pub mod statustree; diff --git a/src/keys.rs b/src/keys.rs index 7c4e46d468..9728769d10 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -36,6 +36,7 @@ pub struct KeyConfig { pub exit_popup: KeyEvent, pub open_commit: KeyEvent, pub open_commit_editor: KeyEvent, + pub show_find_commit_text_input: KeyEvent, pub open_help: KeyEvent, pub move_left: KeyEvent, pub move_right: KeyEvent, @@ -96,6 +97,7 @@ impl Default for KeyConfig { exit_popup: KeyEvent { code: KeyCode::Esc, modifiers: KeyModifiers::empty()}, open_commit: KeyEvent { code: KeyCode::Char('c'), modifiers: KeyModifiers::empty()}, open_commit_editor: KeyEvent { code: KeyCode::Char('e'), modifiers:KeyModifiers::CONTROL}, + show_find_commit_text_input: KeyEvent {code: KeyCode::Char('s'), modifiers: KeyModifiers::empty()}, open_help: KeyEvent { code: KeyCode::Char('h'), modifiers: KeyModifiers::empty()}, move_left: KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::empty()}, move_right: KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::empty()}, diff --git a/src/queue.rs b/src/queue.rs index c761598ae7..cfa84a5dc7 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -77,6 +77,8 @@ pub enum InternalEvent { /// PushTags, /// + FilterLog(String), + /// OpenFileTree(CommitId), } diff --git a/src/strings.rs b/src/strings.rs index 6ddf3e5e16..053988cceb 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -182,6 +182,12 @@ pub fn confirm_msg_force_push( pub fn log_title(_key_config: &SharedKeyConfig) -> String { "Commit".to_string() } +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() } @@ -386,6 +392,7 @@ pub mod commands { CMD_GROUP_LOG, ) } + pub fn push_tags(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!( @@ -396,6 +403,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.show_find_commit_text_input), + ), + "show find commit box to search by sha, author or message", + CMD_GROUP_LOG, + ) + } + pub fn diff_home_end( key_config: &SharedKeyConfig, ) -> CommandText { diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index 616bbcdf9e..9ed6f6a908 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -1,8 +1,11 @@ use crate::{ components::{ + async_commit_filter::{ + AsyncCommitFilterer, FilterBy, FilterStatus, + }, visibility_blocking, CommandBlocking, CommandInfo, CommitDetailsComponent, CommitList, Component, - DrawableComponent, EventState, + DrawableComponent, EventState, FindCommitComponent, }, keys::SharedKeyConfig, queue::{InternalEvent, Queue}, @@ -17,6 +20,7 @@ use asyncgit::{ }; use crossbeam_channel::Sender; use crossterm::event::Event; +use std::convert::TryFrom; use std::time::Duration; use sync::CommitTags; use tui::{ @@ -31,12 +35,16 @@ const SLICE_SIZE: usize = 1200; pub struct Revlog { commit_details: CommitDetailsComponent, list: CommitList, + find_commit: FindCommitComponent, + async_filter: AsyncCommitFilterer, git_log: AsyncLog, git_tags: AsyncTags, queue: Queue, visible: bool, branch_name: cached::BranchName, key_config: SharedKeyConfig, + is_filtering: bool, + filter_string: String, } impl Revlog { @@ -47,6 +55,8 @@ impl Revlog { theme: SharedTheme, key_config: SharedKeyConfig, ) -> Self { + let log = AsyncLog::new(sender); + let tags = AsyncTags::new(sender); Self { queue: queue.clone(), commit_details: CommitDetailsComponent::new( @@ -57,14 +67,26 @@ impl Revlog { ), list: CommitList::new( &strings::log_title(&key_config), + theme.clone(), + key_config.clone(), + ), + find_commit: FindCommitComponent::new( + queue.clone(), theme, key_config.clone(), ), - git_log: AsyncLog::new(sender), - git_tags: AsyncTags::new(sender), + async_filter: AsyncCommitFilterer::new( + log.clone(), + tags.clone(), + sender, + ), + git_log: log, + git_tags: tags, visible: false, branch_name: cached::BranchName::new(CWD), key_config, + is_filtering: false, + filter_string: "".to_string(), } } @@ -72,16 +94,20 @@ 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.commit_details.any_work_pending() } /// 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 { + self.list.set_total_count(self.async_filter.count()); + self.async_filter.fetch() == FilterStatus::Filtering + } else { + self.list.set_total_count(self.git_log.count()?); + self.git_log.fetch()? == FetchStatus::Started + }; let selection = self.list.selection(); let selection_max = self.list.selection_max(); @@ -134,11 +160,22 @@ impl Revlog { let want_min = self.list.selection().saturating_sub(SLICE_SIZE / 2); - let commits = sync::get_commits_info( - CWD, - &self.git_log.get_slice(want_min, SLICE_SIZE)?, - self.list.current_size().0.into(), - ); + let commits = if self.is_filtering { + self.async_filter + .get_filter_items( + want_min, + SLICE_SIZE, + self.list.current_size().0.into(), + ) + .map_err(|e| anyhow::anyhow!(e.to_string())) + } else { + sync::get_commits_info( + CWD, + &self.git_log.get_slice(want_min, SLICE_SIZE)?, + self.list.current_size().0.into(), + ) + .map_err(|e| anyhow::anyhow!(e.to_string())) + }; if let Ok(commits) = commits { self.list.items().set_items(want_min, commits); @@ -166,6 +203,145 @@ impl Revlog { tags.and_then(|tags| tags.get(&commit).cloned()) }) } + + /// 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). + /// + /// Currently parentheses in the `filter_by_str` are not supported. + /// They should be removed by `Self::pre_process_string`. + 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_string(), + 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(); + } + + and_vec.push(( + split_str + .next() + .unwrap_or("") + .trim_start() + .to_string(), + 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(); + let pre_processed_string = + Self::pre_process_string(filter_by.to_string()); + let trimmed_string = + pre_processed_string.trim().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(&trimmed_string); + self.async_filter + .start_filter(filter_strings) + .map_err(|e| anyhow::anyhow!(e.to_string()))?; + self.is_filtering = true; + } + return self.update(); + } + Ok(()) + } + + /// pre process string to remove any brackets + pub fn pre_process_string(mut s: String) -> String { + while s.contains("&&(") { + let before = s.clone(); + s = Self::remove_out_brackets(&s); + if s == before { + break; + } + } + s + } + + /// Remove the brakcets, replacing them with the unbracketed 'full' expression + pub fn remove_out_brackets(s: &str) -> String { + if let Some(first_bracket) = s.find("&&(") { + let (first, rest_of_string) = + s.split_at(first_bracket + 3); + if let Some(last_bracket) = + Self::get_ending_bracket(rest_of_string) + { + let mut v = vec![]; + let (second, third) = + rest_of_string.split_at(last_bracket); + if let Some((first, third)) = first + .strip_suffix('(') + .zip(third.strip_prefix(')')) + { + for inside_bracket_item in second.split("||") { + // Append first, prepend third onto bracket element + v.push(format!( + "{}{}{}", + first, inside_bracket_item, third + )); + } + return v.join("||"); + } + } + } + s.to_string() + } + + /// Get outer matching brakets in a string + pub fn get_ending_bracket(s: &str) -> Option { + let mut brack_count = 0; + let mut ending_brakcet_pos = None; + for (i, c) in s.chars().enumerate() { + if c == '(' { + brack_count += 1; + } else if c == ')' { + if brack_count == 0 { + // Found + ending_brakcet_pos = Some(i); + break; + } + brack_count -= 1; + } + } + ending_brakcet_pos + } } impl DrawableComponent for Revlog { @@ -174,20 +350,49 @@ 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])?; - self.commit_details.draw(f, chunks[1])?; + 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])?; + self.commit_details.draw(f, 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)?; } @@ -199,7 +404,10 @@ impl DrawableComponent for Revlog { impl Component for Revlog { 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()?; @@ -249,6 +457,16 @@ impl Component for Revlog { .borrow_mut() .push_back(InternalEvent::SelectBranch); return Ok(EventState::Consumed); + } else if k + == self.key_config.show_find_commit_text_input + { + self.find_commit.toggle_visible()?; + self.find_commit.focus(true); + return Ok(EventState::Consumed); + } else if k == self.key_config.exit_popup { + self.filter("")?; + self.find_commit.clear_input(); + self.update()?; } else if k == self.key_config.open_file_tree { return self.selected_commit().map_or( Ok(EventState::NotConsumed), @@ -314,6 +532,12 @@ impl Component for Revlog { self.visible || force_all, )); + out.push(CommandInfo::new( + strings::commands::find_commit(&self.key_config), + true, + self.visible || force_all, + )); + out.push(CommandInfo::new( strings::commands::inspect_file_tree(&self.key_config), self.selected_commit().is_some(), @@ -340,3 +564,153 @@ 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(":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(":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)] + ] + ); + } + + #[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/src/tabs/stashlist.rs b/src/tabs/stashlist.rs index f9650f4820..ce4ae24c28 100644 --- a/src/tabs/stashlist.rs +++ b/src/tabs/stashlist.rs @@ -48,7 +48,7 @@ impl StashList { let commits = sync::get_commits_info(CWD, stashes.as_slice(), 100)?; - self.list.set_count_total(commits.len()); + self.list.set_total_count(commits.len()); self.list.items().set_items(0, commits); } diff --git a/vim_style_key_config.ron b/vim_style_key_config.ron index 29b8599116..803f93d3e2 100644 --- a/vim_style_key_config.ron +++ b/vim_style_key_config.ron @@ -79,6 +79,9 @@ force_push: ( code: Char('P'), modifiers: ( bits: 1,),), pull: ( code: Char('f'), modifiers: ( bits: 0,),), + show_find_commit_text_input: ( code: Char('s'), modifiers: ( bits: 0,),), + focus_find_commit: ( code: Char('j'), modifiers: ( bits: 3,),), + open_file_tree: ( code: Char('F'), modifiers: ( bits: 1,),), //removed in 0.11