diff --git a/Cargo.lock b/Cargo.lock index 2460e1d6c0..cefaeac796 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,6 +364,7 @@ dependencies = [ "dirs-next", "itertools", "log", + "parking_lot", "pprof", "rayon-core", "ron", diff --git a/Cargo.toml b/Cargo.toml index a0cf5b1a0b..ba73d09900 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ serde = "1.0" anyhow = "1.0.38" unicode-width = "0.1" textwrap = "0.13" +parking_lot = "0.11" [target.'cfg(all(target_family="unix",not(target_os="macos")))'.dependencies] which = "4.0" diff --git a/README.md b/README.md index b45102d3a1..9ae716c46c 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ - Stashing (save, apply, drop, and inspect) - Push to remote - Branch List (create, rename, delete) -- Browse commit log, diff committed changes +- Browse commit log, diff committed changes, search/filter commits - Scalable terminal UI layout - Async [input polling](assets/perf_compare.jpg) - Async git API for fluid control diff --git a/assets/vim_style_key_config.ron b/assets/vim_style_key_config.ron index b1939ffcf8..df1b2e2ca8 100644 --- a/assets/vim_style_key_config.ron +++ b/assets/vim_style_key_config.ron @@ -71,6 +71,9 @@ force_push: ( code: Char('P'), modifiers: ( bits: 1,),), fetch: ( 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,),), + //removed in 0.11 //tab_toggle_reverse_windows: ( code: BackTab, modifiers: ( bits: 1,),), ) diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index ffb11218b9..60f7bd6482 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 65740cc1ef..3b747a41cb 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -45,7 +45,7 @@ impl From for CommitId { } /// -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct CommitInfo { /// pub message: String, @@ -108,8 +108,9 @@ pub fn get_message( } } +/// #[inline] -fn limit_str(s: &str, limit: usize) -> &str { +pub fn limit_str(s: &str, limit: usize) -> &str { if let Some(first) = s.lines().next() { let mut limit = limit.min(first.len()); while !first.is_char_boundary(limit) { diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index a5122f68f5..cf53b9784e 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -31,7 +31,9 @@ pub use commit_details::{ get_commit_details, CommitDetails, CommitMessage, }; pub use commit_files::get_commit_files; -pub use commits_info::{get_commits_info, CommitId, CommitInfo}; +pub use commits_info::{ + get_commits_info, limit_str, CommitId, CommitInfo, +}; pub use diff::get_diff_commit; pub use hooks::{ hooks_commit_msg, hooks_post_commit, hooks_pre_commit, HookResult, diff --git a/asyncgit/src/tags.rs b/asyncgit/src/tags.rs index f23b54afcd..b38060f594 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 b9c544b0fa..44f79fe0ae 100644 --- a/src/app.rs +++ b/src/app.rs @@ -350,11 +350,11 @@ impl App { create_branch_popup, rename_branch_popup, select_branch_popup, - help, revlog, status_tab, stashing_tab, - stashlist_tab + stashlist_tab, + help ] ); @@ -542,6 +542,9 @@ impl App { self.push_popup.push(branch, force)?; flags.insert(NeedsUpdate::ALL) } + InternalEvent::FilterLog(string_to_fliter_by) => { + self.revlog.filter(&string_to_fliter_by)? + } }; Ok(flags) diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index 16b071d977..a39777e15f 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..171ff004d4 --- /dev/null +++ b/src/components/find_commit.rs @@ -0,0 +1,119 @@ +use super::{ + textinput::TextInputComponent, CommandBlocking, CommandInfo, + Component, DrawableComponent, +}; +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(true); + } + } + if self.input.event(ev)? { + self.queue.borrow_mut().push_back( + InternalEvent::FilterLog( + self.input.get_text().to_string(), + ), + ); + return Ok(true); + } + } + Ok(false) + } + + 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 38efec75e5..fb8520e84f 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -8,6 +8,7 @@ mod cred; mod diff; mod externaleditor; mod filetree; +mod find_commit; mod help; mod inspect_commit; mod msg; @@ -29,6 +30,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; @@ -39,6 +41,7 @@ pub use select_branch::SelectBranchComponent; 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 2f676dd227..e8849fe4bc 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..0a2efe0baf --- /dev/null +++ b/src/components/utils/async_commit_filter.rs @@ -0,0 +1,404 @@ +use anyhow::Result; +use asyncgit::{ + sync::{self, limit_str, CommitId, CommitInfo, Tags}, + AsyncLog, AsyncNotification, AsyncTags, CWD, +}; +use bitflags::bitflags; +use crossbeam_channel::{Sender, TryRecvError}; +use parking_lot::Mutex; +use std::{ + cell::RefCell, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }, + thread, + time::Duration, +}; + +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; + } +} + +#[derive(PartialEq)] +pub enum FilterStatus { + Filtering, + Finished, +} + +pub struct AsyncCommitFilterer { + git_log: AsyncLog, + git_tags: AsyncTags, + filter_strings: Vec>, + 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 { + filter_strings: Vec::new(), + 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(); + + self.filter_strings = filter_strings.clone(); + + 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(); + let _p = prev_thread_mutex.lock(); + filter_finished.store(false, Ordering::Relaxed); + filter_count.store(0, Ordering::Relaxed); + filtered_commits.lock().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(); + 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(); + 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 = limit_str(&c.message, message_length_limit) + .to_string(); + } + 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 a3fe5652c9..81fc380716 100644 --- a/src/components/utils/mod.rs +++ b/src/components/utils/mod.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Local, NaiveDateTime, Utc}; +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 153869bf86..167434d0d8 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -33,6 +33,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, @@ -87,6 +88,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()}, @@ -208,27 +210,21 @@ impl KeyConfig { | KeyCode::BackTab | KeyCode::Delete | KeyCode::Insert - | KeyCode::Esc => { - format!( - "{}{}", - Self::get_modifier_hint(ev.modifiers), - self.get_key_symbol(ev.code) - ) - } - KeyCode::Char(c) => { - format!( - "{}{}", - Self::get_modifier_hint(ev.modifiers), - c - ) - } - KeyCode::F(u) => { - format!( - "{}F{}", - Self::get_modifier_hint(ev.modifiers), - u - ) - } + | KeyCode::Esc => format!( + "{}{}", + Self::get_modifier_hint(ev.modifiers), + self.get_key_symbol(ev.code) + ), + KeyCode::Char(c) => format!( + "{}{}", + Self::get_modifier_hint(ev.modifiers), + c + ), + KeyCode::F(u) => format!( + "{}F{}", + Self::get_modifier_hint(ev.modifiers), + u + ), KeyCode::Null => Self::get_modifier_hint(ev.modifiers), } } diff --git a/src/queue.rs b/src/queue.rs index dc9c650378..d0e0c7488c 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -62,6 +62,8 @@ pub enum InternalEvent { OpenExternalEditor(Option), /// Push(String, bool), + /// + FilterLog(String), } /// diff --git a/src/strings.rs b/src/strings.rs index 1746279cd1..5d5531ff70 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -128,6 +128,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 tag_commit_popup_title( _key_config: &SharedKeyConfig, ) -> String { @@ -329,6 +335,17 @@ 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 9a2a673520..b08dda5e97 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, + DrawableComponent, FindCommitComponent, }, keys::SharedKeyConfig, queue::{InternalEvent, Queue}, @@ -31,12 +34,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 +54,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 +66,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 +93,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.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 +159,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 +202,160 @@ impl Revlog { tags.and_then(|tags| tags.get(&commit).cloned()) }) } + + fn get_what_to_filter_by( + filter_by_str: &str, + ) -> Vec> { + let mut search_vec = vec![]; + let mut and_vec = Vec::new(); + for or in filter_by_str.split("||") { + for split_sub in or.split("&&") { + if let Some(':') = split_sub.chars().next() { + let mut to_filter_by = FilterBy::empty(); + let mut split_str = + split_sub.split(' ').collect::>(); + if split_str.len() == 1 { + split_str.push(""); + } + let first = split_str[0]; + if first.contains('s') { + to_filter_by |= FilterBy::SHA; + } + if first.contains('a') { + to_filter_by |= FilterBy::AUTHOR; + } + if first.contains('m') { + to_filter_by |= FilterBy::MESSAGE; + } + if first.contains('c') { + to_filter_by |= FilterBy::CASE_SENSITIVE; + } + if first.contains('t') { + to_filter_by |= FilterBy::TAGS; + } + if first.contains('!') { + to_filter_by |= FilterBy::NOT; + } + + if to_filter_by.is_empty() { + to_filter_by = FilterBy::all() + & !FilterBy::NOT + & !FilterBy::CASE_SENSITIVE; + } else if to_filter_by + == FilterBy::CASE_SENSITIVE & FilterBy::NOT + { + FilterBy::all(); + } else if to_filter_by == FilterBy::NOT { + to_filter_by = FilterBy::all() + & !FilterBy::CASE_SENSITIVE + & !FilterBy::TAGS; + } else if to_filter_by == FilterBy::CASE_SENSITIVE + { + to_filter_by = + FilterBy::all() & !FilterBy::NOT; + }; + + and_vec.push(( + split_str[1..].join(" ").trim().to_string(), + to_filter_by, + )); + } else { + and_vec.push(( + split_sub.trim().to_string(), + FilterBy::all() + & !FilterBy::NOT + & !FilterBy::CASE_SENSITIVE, + )); + } + } + 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 == "" { + 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 +364,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 +418,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 { + event_used = self.list.event(ev)?; + } if event_used { self.update()?; @@ -244,6 +466,16 @@ impl Component for Revlog { .borrow_mut() .push_back(InternalEvent::SelectBranch); return Ok(true); + } else if k + == self.key_config.show_find_commit_text_input + { + self.find_commit.toggle_visible()?; + self.find_commit.focus(true); + return Ok(true); + } else if k == self.key_config.exit_popup { + self.filter("")?; + self.find_commit.clear_input(); + self.update()?; } } } @@ -293,6 +525,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, + )); + visibility_blocking(self) } diff --git a/src/tabs/stashlist.rs b/src/tabs/stashlist.rs index 98f6c0139a..d6782c61bb 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); }