diff --git a/CHANGELOG.md b/CHANGELOG.md index 7868e512..d98691b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +**search commits** + +![commit-search](assets/log-search.gif) + **visualize empty lines in diff better** ![diff-empty-line](assets/diff-empty-line.png) @@ -20,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Future additions of colors etc. will not break existing themes anymore ### Added +* search commits by files in diff or commit message ([#1791](https://github.com/extrawurst/gitui/issues/1791)) * 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)) * support for sign-off of commits [[@domtac](https://github.com/domtac)]([#1757](https://github.com/extrawurst/gitui/issues/1757)) diff --git a/Cargo.lock b/Cargo.lock index 22a98bad..68e62122 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,9 +69,11 @@ checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" name = "asyncgit" version = "0.23.0" dependencies = [ + "bitflags", "crossbeam-channel", "easy-cast", "env_logger", + "fuzzy-matcher", "git2", "invalidstring", "log", diff --git a/README.md b/README.md index 9abf60df..974ae4d0 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,6 @@ For a [RustBerlin meetup presentation](https://youtu.be/rpilJV-eIVw?t=5334) ([sl These are the high level goals before calling out `1.0`: -* log search (commit, author, sha) ([#1791](https://github.com/extrawurst/gitui/issues/1791)) * visualize branching structure in log tab ([#81](https://github.com/extrawurst/gitui/issues/81)) * interactive rebase ([#32](https://github.com/extrawurst/gitui/issues/32)) diff --git a/assets/log-search.gif b/assets/log-search.gif new file mode 100644 index 00000000..1aa462fe Binary files /dev/null and b/assets/log-search.gif differ diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index d0a42d8f..cb241d15 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -12,8 +12,10 @@ categories = ["concurrency", "asynchronous"] keywords = ["git"] [dependencies] +bitflags = "1" crossbeam-channel = "0.5" easy-cast = "0.5" +fuzzy-matcher = "0.3" git2 = "0.17" log = "0.4" # git2 = { path = "../../extern/git2-rs", features = ["vendored-openssl"]} diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index 18621436..a42fe55f 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -11,7 +11,7 @@ use std::{ Arc, Mutex, }, thread, - time::Duration, + time::{Duration, Instant}, }; /// @@ -25,9 +25,16 @@ pub enum FetchStatus { Started, } +/// +pub struct AsyncLogResult { + /// + pub commits: Vec, + /// + pub duration: Duration, +} /// pub struct AsyncLog { - current: Arc>>, + current: Arc>, current_head: Arc>>, sender: Sender, pending: Arc, @@ -49,7 +56,10 @@ impl AsyncLog { ) -> Self { Self { repo, - current: Arc::new(Mutex::new(Vec::new())), + current: Arc::new(Mutex::new(AsyncLogResult { + commits: Vec::new(), + duration: Duration::default(), + })), current_head: Arc::new(Mutex::new(None)), sender: sender.clone(), pending: Arc::new(AtomicBool::new(false)), @@ -60,7 +70,7 @@ impl AsyncLog { /// pub fn count(&self) -> Result { - Ok(self.current.lock()?.len()) + Ok(self.current.lock()?.commits.len()) } /// @@ -69,7 +79,7 @@ impl AsyncLog { start_index: usize, amount: usize, ) -> Result> { - let list = self.current.lock()?; + let list = &self.current.lock()?.commits; let list_len = list.len(); let min = start_index.min(list_len); let max = min + amount; @@ -77,9 +87,20 @@ impl AsyncLog { Ok(list[min..max].to_vec()) } + /// + pub fn get_items(&self) -> Result> { + let list = &self.current.lock()?.commits; + Ok(list.clone()) + } + + /// + pub fn get_last_duration(&self) -> Result { + Ok(self.current.lock()?.duration) + } + /// pub fn position(&self, id: CommitId) -> Result> { - let list = self.current.lock()?; + let list = &self.current.lock()?.commits; let position = list.iter().position(|&x| x == id); Ok(position) @@ -160,11 +181,13 @@ impl AsyncLog { fn fetch_helper( repo_path: &RepoPath, - arc_current: &Arc>>, + arc_current: &Arc>, arc_background: &Arc, sender: &Sender, filter: Option, ) -> Result<()> { + let start_time = Instant::now(); + let mut entries = Vec::with_capacity(LIMIT_COUNT); let r = repo(repo_path)?; let mut walker = @@ -175,7 +198,8 @@ impl AsyncLog { if !res_is_err { let mut current = arc_current.lock()?; - current.extend(entries.iter()); + current.commits.extend(entries.iter()); + current.duration = start_time.elapsed(); } if res_is_err || entries.len() <= 1 { @@ -196,7 +220,7 @@ impl AsyncLog { } fn clear(&mut self) -> Result<()> { - self.current.lock()?.clear(); + self.current.lock()?.commits.clear(); *self.current_head.lock()? = None; Ok(()) } diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs index a922f2d2..e9f0422d 100644 --- a/asyncgit/src/sync/logwalker.rs +++ b/asyncgit/src/sync/logwalker.rs @@ -1,6 +1,9 @@ +#![allow(dead_code)] use super::CommitId; use crate::{error::Result, sync::commit_files::get_commit_diff}; -use git2::{Commit, Oid, Repository}; +use bitflags::bitflags; +use fuzzy_matcher::FuzzyMatcher; +use git2::{Commit, Diff, Oid, Repository}; use std::{ cmp::Ordering, collections::{BinaryHeap, HashSet}, @@ -55,6 +58,163 @@ pub fn diff_contains_file(file_path: String) -> LogWalkerFilter { )) } +bitflags! { + /// + pub struct SearchFields: u32 { + /// + const MESSAGE = 0b0000_0001; + /// + const FILENAMES = 0b0000_0010; + //TODO: + // const COMMIT_HASHES = 0b0000_0100; + // /// + // const DATES = 0b0000_1000; + // /// + // const AUTHORS = 0b0001_0000; + // /// + // const DIFFS = 0b0010_0000; + } +} + +impl Default for SearchFields { + fn default() -> Self { + Self::MESSAGE + } +} + +bitflags! { + /// + pub struct SearchOptions: u32 { + /// + const CASE_SENSITIVE = 0b0000_0001; + /// + const FUZZY_SEARCH = 0b0000_0010; + } +} + +impl Default for SearchOptions { + fn default() -> Self { + Self::empty() + } +} + +/// +#[derive(Default, Debug, Clone)] +pub struct LogFilterSearchOptions { + /// + pub search_pattern: String, + /// + pub fields: SearchFields, + /// + pub options: SearchOptions, +} + +/// +#[derive(Default)] +pub struct LogFilterSearch { + /// + pub matcher: fuzzy_matcher::skim::SkimMatcherV2, + /// + pub options: LogFilterSearchOptions, +} + +impl LogFilterSearch { + /// + pub fn new(options: LogFilterSearchOptions) -> Self { + let mut options = options; + if !options.options.contains(SearchOptions::CASE_SENSITIVE) { + options.search_pattern = + options.search_pattern.to_lowercase(); + } + Self { + matcher: fuzzy_matcher::skim::SkimMatcherV2::default(), + options, + } + } + + fn match_diff(&self, diff: &Diff<'_>) -> bool { + diff.deltas().any(|delta| { + if delta + .new_file() + .path() + .and_then(|file| file.as_os_str().to_str()) + .map(|file| self.match_text(file)) + .unwrap_or_default() + { + return true; + } + + delta + .old_file() + .path() + .and_then(|file| file.as_os_str().to_str()) + .map(|file| self.match_text(file)) + .unwrap_or_default() + }) + } + + /// + pub fn match_text(&self, text: &str) -> bool { + if self.options.options.contains(SearchOptions::FUZZY_SEARCH) + { + self.matcher + .fuzzy_match( + text, + self.options.search_pattern.as_str(), + ) + .is_some() + } else if self + .options + .options + .contains(SearchOptions::CASE_SENSITIVE) + { + text.contains(self.options.search_pattern.as_str()) + } else { + text.to_lowercase() + .contains(self.options.search_pattern.as_str()) + } + } +} + +/// +pub fn filter_commit_by_search( + filter: LogFilterSearch, +) -> LogWalkerFilter { + Arc::new(Box::new( + move |repo: &Repository, + commit_id: &CommitId| + -> Result { + let commit = repo.find_commit((*commit_id).into())?; + + let msg_match = filter + .options + .fields + .contains(SearchFields::MESSAGE) + .then(|| { + commit.message().map(|msg| filter.match_text(msg)) + }) + .flatten() + .unwrap_or_default(); + + let file_match = filter + .options + .fields + .contains(SearchFields::FILENAMES) + .then(|| { + get_commit_diff( + repo, *commit_id, None, None, None, + ) + .ok() + }) + .flatten() + .map(|diff| filter.match_diff(&diff)) + .unwrap_or_default(); + + Ok(msg_match || file_match) + }, + )) +} + /// pub struct LogWalker<'a> { commits: BinaryHeap>, @@ -130,6 +290,7 @@ impl<'a> LogWalker<'a> { mod tests { use super::*; use crate::error::Result; + use crate::sync::tests::write_commit_file; use crate::sync::RepoPath; use crate::sync::{ commit, get_commits_info, stage_add_file, @@ -246,4 +407,51 @@ mod tests { Ok(()) } + + #[test] + fn test_logwalker_with_filter_search() { + let (_td, repo) = repo_init_empty().unwrap(); + + write_commit_file(&repo, "foo", "a", "commit1"); + let second_commit_id = write_commit_file( + &repo, + "baz", + "a", + "my commit msg (#2)", + ); + write_commit_file(&repo, "foo", "b", "commit3"); + + let log_filter = filter_commit_by_search( + LogFilterSearch::new(LogFilterSearchOptions { + fields: SearchFields::MESSAGE, + options: SearchOptions::FUZZY_SEARCH, + search_pattern: String::from("my msg"), + }), + ); + + let mut items = Vec::new(); + let mut walker = LogWalker::new(&repo, 100) + .unwrap() + .filter(Some(log_filter)); + walker.read(&mut items).unwrap(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0], second_commit_id); + + let log_filter = filter_commit_by_search( + LogFilterSearch::new(LogFilterSearchOptions { + fields: SearchFields::FILENAMES, + options: SearchOptions::FUZZY_SEARCH, + search_pattern: String::from("fo"), + }), + ); + + let mut items = Vec::new(); + let mut walker = LogWalker::new(&repo, 100) + .unwrap() + .filter(Some(log_filter)); + walker.read(&mut items).unwrap(); + + assert_eq!(items.len(), 2); + } } diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index a752e07b..f0b8c0f0 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -63,7 +63,11 @@ pub use hooks::{ }; pub use hunks::{reset_hunk, stage_hunk, unstage_hunk}; pub use ignore::add_to_ignore; -pub use logwalker::{diff_contains_file, LogWalker, LogWalkerFilter}; +pub use logwalker::{ + diff_contains_file, filter_commit_by_search, LogFilterSearch, + LogFilterSearchOptions, LogWalker, LogWalkerFilter, SearchFields, + SearchOptions, +}; pub use merge::{ abort_pending_rebase, abort_pending_state, continue_pending_rebase, merge_branch, merge_commit, merge_msg, diff --git a/src/app.rs b/src/app.rs index 9751b096..dbfa1a59 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,10 +8,10 @@ use crate::{ ConfirmComponent, CreateBranchComponent, DrawableComponent, ExternalEditorComponent, FetchComponent, FileRevlogComponent, FuzzyFindPopup, FuzzyFinderTarget, HelpComponent, - InspectCommitComponent, MsgComponent, OptionsPopupComponent, - PullComponent, PushComponent, PushTagsComponent, - RenameBranchComponent, ResetPopupComponent, - RevisionFilesPopup, StashMsgComponent, + InspectCommitComponent, LogSearchPopupComponent, + MsgComponent, OptionsPopupComponent, PullComponent, + PushComponent, PushTagsComponent, RenameBranchComponent, + ResetPopupComponent, RevisionFilesPopup, StashMsgComponent, SubmodulesListComponent, TagCommitComponent, TagListComponent, }, @@ -74,6 +74,7 @@ pub struct App { external_editor_popup: ExternalEditorComponent, revision_files_popup: RevisionFilesPopup, fuzzy_find_popup: FuzzyFindPopup, + log_search_popup: LogSearchPopupComponent, push_popup: PushComponent, push_tags_popup: PushTagsComponent, pull_popup: PullComponent, @@ -271,6 +272,11 @@ impl App { theme.clone(), key_config.clone(), ), + log_search_popup: LogSearchPopupComponent::new( + &queue, + theme.clone(), + key_config.clone(), + ), fuzzy_find_popup: FuzzyFindPopup::new( &queue, theme.clone(), @@ -580,6 +586,7 @@ impl App { accessors!( self, [ + log_search_popup, fuzzy_find_popup, msg, reset, @@ -632,6 +639,7 @@ impl App { rename_branch_popup, revision_files_popup, fuzzy_find_popup, + log_search_popup, push_popup, push_tags_popup, pull_popup, @@ -895,6 +903,11 @@ impl App { flags .insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS); } + InternalEvent::OpenLogSearchPopup => { + self.log_search_popup.open()?; + flags + .insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS); + } InternalEvent::OptionSwitched(o) => { match o { AppOption::StatusShowUntracked => { @@ -961,6 +974,9 @@ impl App { InternalEvent::OpenResetPopup(id) => { self.reset_popup.open(id)?; } + InternalEvent::CommitSearch(options) => { + self.revlog.search(options)?; + } }; Ok(flags) diff --git a/src/components/command.rs b/src/components/command.rs index 182069c5..fb117193 100644 --- a/src/components/command.rs +++ b/src/components/command.rs @@ -1,3 +1,5 @@ +use crate::strings::order; + /// #[derive(Clone, PartialEq, PartialOrd, Ord, Eq)] pub struct CommandText { @@ -60,7 +62,7 @@ impl CommandInfo { enabled, quick_bar: true, available, - order: 0, + order: order::AVERAGE, } } diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index ba799eaf..b73ceb46 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -27,8 +27,12 @@ use ratatui::{ Frame, }; use std::{ - borrow::Cow, cell::Cell, cmp, collections::BTreeMap, - convert::TryFrom, time::Instant, + borrow::Cow, + cell::Cell, + cmp, + collections::{BTreeMap, HashSet}, + convert::TryFrom, + time::Instant, }; const ELEMENTS_PER_LINE: usize = 9; @@ -80,11 +84,6 @@ impl CommitList { } } - /// - pub fn items(&mut self) -> &mut ItemBatch { - &mut self.items - } - /// pub const fn selection(&self) -> usize { self.selection @@ -206,6 +205,57 @@ impl CommitList { } fn move_selection(&mut self, scroll: ScrollType) -> Result { + let needs_update = if self.items.highlighting() { + self.move_selection_highlighting(scroll)? + } else { + self.move_selection_normal(scroll)? + }; + + Ok(needs_update) + } + + fn move_selection_highlighting( + &mut self, + scroll: ScrollType, + ) -> Result { + let old_selection = self.selection; + + loop { + let new_selection = match scroll { + ScrollType::Up => self.selection.saturating_sub(1), + ScrollType::Down => self.selection.saturating_add(1), + + //TODO: support this? + // ScrollType::Home => 0, + // ScrollType::End => self.selection_max(), + _ => return Ok(false), + }; + + let new_selection = + cmp::min(new_selection, self.selection_max()); + let selection_changed = new_selection != self.selection; + + if !selection_changed { + self.selection = old_selection; + return Ok(false); + } + + self.selection = new_selection; + + if self + .selected_entry() + .map(|entry| entry.highlighted) + .unwrap_or_default() + { + return Ok(true); + } + } + } + + fn move_selection_normal( + &mut self, + scroll: ScrollType, + ) -> Result { self.update_scroll_speed(); #[allow(clippy::cast_possible_truncation)] @@ -235,7 +285,6 @@ impl CommitList { let new_selection = cmp::min(new_selection, self.selection_max()); - let needs_update = new_selection != self.selection; self.selection = new_selection; @@ -297,6 +346,7 @@ impl CommitList { #[allow(clippy::too_many_arguments)] fn get_entry_to_add<'a>( + &self, e: &'a LogEntry, selected: bool, tags: Option, @@ -315,6 +365,9 @@ impl CommitList { let splitter = Span::styled(splitter_txt, theme.text(true, selected)); + let normal = !self.items.highlighting() + || (self.items.highlighting() && e.highlighted); + // marker if let Some(marked) = marked { txt.push(Span::styled( @@ -328,18 +381,34 @@ impl CommitList { txt.push(splitter.clone()); } + let style_hash = normal + .then(|| theme.commit_hash(selected)) + .unwrap_or_else(|| theme.commit_unhighlighted()); + let style_time = normal + .then(|| theme.commit_time(selected)) + .unwrap_or_else(|| theme.commit_unhighlighted()); + let style_author = normal + .then(|| theme.commit_author(selected)) + .unwrap_or_else(|| theme.commit_unhighlighted()); + let style_tags = normal + .then(|| theme.tags(selected)) + .unwrap_or_else(|| theme.commit_unhighlighted()); + let style_branches = normal + .then(|| theme.branch(selected, true)) + .unwrap_or_else(|| theme.commit_unhighlighted()); + let style_msg = normal + .then(|| theme.text(true, selected)) + .unwrap_or_else(|| theme.commit_unhighlighted()); + // commit hash - txt.push(Span::styled( - Cow::from(&*e.hash_short), - theme.commit_hash(selected), - )); + txt.push(Span::styled(Cow::from(&*e.hash_short), style_hash)); txt.push(splitter.clone()); // commit timestamp txt.push(Span::styled( Cow::from(e.time_to_string(now)), - theme.commit_time(selected), + style_time, )); txt.push(splitter.clone()); @@ -349,33 +418,23 @@ impl CommitList { let author = string_width_align(&e.author, author_width); // commit author - txt.push(Span::styled::( - author, - theme.commit_author(selected), - )); + txt.push(Span::styled::(author, style_author)); txt.push(splitter.clone()); // commit tags if let Some(tags) = tags { txt.push(splitter.clone()); - txt.push(Span::styled(tags, theme.tags(selected))); + txt.push(Span::styled(tags, style_tags)); } if let Some(local_branches) = local_branches { txt.push(splitter.clone()); - txt.push(Span::styled( - local_branches, - theme.branch(selected, true), - )); + txt.push(Span::styled(local_branches, style_branches)); } - if let Some(remote_branches) = remote_branches { txt.push(splitter.clone()); - txt.push(Span::styled( - remote_branches, - theme.branch(selected, true), - )); + txt.push(Span::styled(remote_branches, style_branches)); } txt.push(splitter); @@ -387,7 +446,7 @@ impl CommitList { // commit msg txt.push(Span::styled( format!("{:message_width$}", &e.msg), - theme.text(true, selected), + style_msg, )); Line::from(txt) @@ -428,57 +487,18 @@ impl CommitList { .join(" ") }); - let remote_branches = self - .remote_branches - .get(&e.id) - .and_then(|remote_branches| { - let filtered_branches: Vec<_> = remote_branches - .iter() - .filter(|remote_branch| { - self.local_branches - .get(&e.id) - .map_or(true, |local_branch| { - local_branch.iter().any( - |local_branch| { - let has_corresponding_local_branch = match &local_branch.details { - BranchDetails::Local(details) => - details - .upstream - .as_ref() - .map_or(false, |upstream| upstream.reference == remote_branch.reference), - BranchDetails::Remote(_) => - false, - }; - - !has_corresponding_local_branch - }, - ) - }) - }) - .map(|remote_branch| { - format!("[{0}]", remote_branch.name) - }) - .collect(); - - if filtered_branches.is_empty() { - None - } else { - Some(filtered_branches.join(" ")) - } - }); - let marked = if any_marked { self.is_marked(&e.id) } else { None }; - txt.push(Self::get_entry_to_add( + txt.push(self.get_entry_to_add( e, idx + self.scroll_top.get() == selection, tags, local_branches, - remote_branches, + self.remote_branches_string(e), &self.theme, width, now, @@ -489,6 +509,51 @@ impl CommitList { txt } + fn remote_branches_string(&self, e: &LogEntry) -> Option { + self.remote_branches.get(&e.id).and_then(|remote_branches| { + let filtered_branches: Vec<_> = remote_branches + .iter() + .filter(|remote_branch| { + self.local_branches.get(&e.id).map_or( + true, + |local_branch| { + local_branch.iter().any(|local_branch| { + let has_corresponding_local_branch = + match &local_branch.details { + BranchDetails::Local( + details, + ) => details + .upstream + .as_ref() + .map_or( + false, + |upstream| { + upstream.reference == remote_branch.reference + }, + ), + BranchDetails::Remote(_) => { + false + } + }; + + !has_corresponding_local_branch + }) + }, + ) + }) + .map(|remote_branch| { + format!("[{0}]", remote_branch.name) + }) + .collect(); + + if filtered_branches.is_empty() { + None + } else { + Some(filtered_branches.join(" ")) + } + }) + } + #[allow(clippy::missing_const_for_fn)] fn relative_selection(&self) -> usize { self.selection.saturating_sub(self.items.index_offset()) @@ -537,6 +602,67 @@ impl CommitList { .push(remote_branch); } } + + /// + pub fn set_items( + &mut self, + start_index: usize, + commits: Vec, + highlighted: &Option>, + ) { + self.items.set_items(start_index, commits, highlighted); + + if self.items.highlighting() { + self.select_next_highlight(); + } + } + + fn select_next_highlight(&mut self) { + let old_selection = self.selection; + + let mut offset = 0; + loop { + let hit_upper_bound = + old_selection + offset > self.selection_max(); + let hit_lower_bound = offset > old_selection; + + if !hit_upper_bound { + self.selection = old_selection + offset; + + if self + .selected_entry() + .map(|entry| entry.highlighted) + .unwrap_or_default() + { + break; + } + } + + if !hit_lower_bound { + self.selection = old_selection - offset; + + if self + .selected_entry() + .map(|entry| entry.highlighted) + .unwrap_or_default() + { + break; + } + } + + if hit_lower_bound && hit_upper_bound { + self.selection = old_selection; + break; + } + + offset += 1; + } + } + + /// + pub fn needs_data(&self, idx: usize, idx_max: usize) -> bool { + self.items.needs_data(idx, idx_max) + } } impl DrawableComponent for CommitList { diff --git a/src/components/file_revlog.rs b/src/components/file_revlog.rs index 3d6a3934..6f62a9a1 100644 --- a/src/components/file_revlog.rs +++ b/src/components/file_revlog.rs @@ -257,7 +257,7 @@ impl FileRevlogComponent { // // [gitui-issue]: https://github.com/extrawurst/gitui/issues/1560 // [tui-issue]: https://github.com/fdehau/tui-rs/issues/626 - self.items.set_items(0, commits); + self.items.set_items(0, commits, &None); } self.table_state.set(table_state); diff --git a/src/components/log_search_popup.rs b/src/components/log_search_popup.rs new file mode 100644 index 00000000..ef13b554 --- /dev/null +++ b/src/components/log_search_popup.rs @@ -0,0 +1,388 @@ +use super::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, TextInputComponent, +}; +use crate::{ + keys::{key_match, SharedKeyConfig}, + queue::{InternalEvent, Queue}, + strings::{self}, + ui::{self, style::SharedTheme}, +}; +use anyhow::Result; +use asyncgit::sync::{ + LogFilterSearchOptions, SearchFields, SearchOptions, +}; +use crossterm::event::Event; +use ratatui::{ + backend::Backend, + layout::{ + Alignment, Constraint, Direction, Layout, Margin, Rect, + }, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +enum Selection { + EnterText, + FuzzyOption, + CaseOption, + MessageSearch, + FilenameSearch, +} + +pub struct LogSearchPopupComponent { + queue: Queue, + visible: bool, + selection: Selection, + key_config: SharedKeyConfig, + find_text: TextInputComponent, + options: (SearchFields, SearchOptions), + theme: SharedTheme, +} + +impl LogSearchPopupComponent { + /// + pub fn new( + queue: &Queue, + theme: SharedTheme, + key_config: SharedKeyConfig, + ) -> Self { + let mut find_text = TextInputComponent::new( + theme.clone(), + key_config.clone(), + "", + "search text", + false, + ); + find_text.embed(); + + Self { + queue: queue.clone(), + visible: false, + key_config, + options: ( + SearchFields::default(), + SearchOptions::default(), + ), + theme, + find_text, + selection: Selection::EnterText, + } + } + + pub fn open(&mut self) -> Result<()> { + self.show()?; + self.find_text.show()?; + self.find_text.set_text(String::new()); + + Ok(()) + } + + fn execute_search(&mut self) { + self.hide(); + + if !self.find_text.get_text().trim().is_empty() { + self.queue.push(InternalEvent::CommitSearch( + LogFilterSearchOptions { + fields: self.options.0, + options: self.options.1, + search_pattern: self + .find_text + .get_text() + .to_string(), + }, + )); + } + } + + fn get_text_options(&self) -> Vec { + let x_message = + if self.options.0.contains(SearchFields::MESSAGE) { + "X" + } else { + " " + }; + + let x_files = + if self.options.0.contains(SearchFields::FILENAMES) { + "X" + } else { + " " + }; + + let x_opt_fuzzy = + if self.options.1.contains(SearchOptions::FUZZY_SEARCH) { + "X" + } else { + " " + }; + + let x_opt_casesensitive = + if self.options.1.contains(SearchOptions::CASE_SENSITIVE) + { + "X" + } else { + " " + }; + + vec![ + Line::from(vec![Span::styled( + format!("[{x_opt_fuzzy}] fuzzy search"), + self.theme.text( + matches!(self.selection, Selection::FuzzyOption), + false, + ), + )]), + Line::from(vec![Span::styled( + format!("[{x_opt_casesensitive}] case sensitive"), + self.theme.text( + matches!(self.selection, Selection::CaseOption), + false, + ), + )]), + Line::from(vec![Span::styled( + format!("[{x_message}] messages",), + self.theme.text( + matches!( + self.selection, + Selection::MessageSearch + ), + false, + ), + )]), + Line::from(vec![Span::styled( + format!("[{x_files}] commited files",), + self.theme.text( + matches!( + self.selection, + Selection::FilenameSearch + ), + false, + ), + )]), + // Line::from(vec![Span::styled( + // "[ ] changes (soon)", + // theme, + // )]), + // Line::from(vec![Span::styled( + // "[ ] authors (soon)", + // theme, + // )]), + // Line::from(vec![Span::styled( + // "[ ] hashes (soon)", + // theme, + // )]), + ] + } + + fn option_selected(&self) -> bool { + !matches!(self.selection, Selection::EnterText) + } + + fn toggle_option(&mut self) { + match self.selection { + Selection::EnterText => (), + Selection::FuzzyOption => { + self.options.1.toggle(SearchOptions::FUZZY_SEARCH); + } + Selection::CaseOption => { + self.options.1.toggle(SearchOptions::CASE_SENSITIVE); + } + Selection::MessageSearch => { + self.options.0.toggle(SearchFields::MESSAGE); + + if !self.options.0.contains(SearchFields::MESSAGE) { + self.options.0.set(SearchFields::FILENAMES, true); + } + } + Selection::FilenameSearch => { + self.options.0.toggle(SearchFields::FILENAMES); + + if !self.options.0.contains(SearchFields::FILENAMES) { + self.options.0.set(SearchFields::MESSAGE, true); + } + } + } + } + + fn move_selection(&mut self, arg: bool) { + if arg { + //up + self.selection = match self.selection { + Selection::EnterText => Selection::FilenameSearch, + Selection::FuzzyOption => Selection::EnterText, + Selection::CaseOption => Selection::FuzzyOption, + Selection::MessageSearch => Selection::CaseOption, + Selection::FilenameSearch => Selection::MessageSearch, + }; + } else { + self.selection = match self.selection { + Selection::EnterText => Selection::FuzzyOption, + Selection::FuzzyOption => Selection::CaseOption, + Selection::CaseOption => Selection::MessageSearch, + Selection::MessageSearch => Selection::FilenameSearch, + Selection::FilenameSearch => Selection::EnterText, + }; + } + } +} + +impl DrawableComponent for LogSearchPopupComponent { + fn draw( + &self, + f: &mut Frame, + area: Rect, + ) -> Result<()> { + if self.is_visible() { + const SIZE: (u16, u16) = (60, 10); + let area = + ui::centered_rect_absolute(SIZE.0, SIZE.1, area); + + f.render_widget(Clear, area); + f.render_widget( + Block::default() + .borders(Borders::all()) + .style(self.theme.title(true)) + .title(Span::styled( + strings::POPUP_TITLE_LOG_SEARCH, + self.theme.title(true), + )), + area, + ); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(1), + Constraint::Percentage(100), + ] + .as_ref(), + ) + .split(area.inner(&Margin { + horizontal: 1, + vertical: 1, + })); + + self.find_text.draw(f, chunks[0])?; + + f.render_widget( + Paragraph::new(self.get_text_options()) + .block( + Block::default() + .borders(Borders::TOP) + .border_style(self.theme.block(true)), + ) + .alignment(Alignment::Left), + chunks[1], + ); + } + + Ok(()) + } +} + +impl Component for LogSearchPopupComponent { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + out.push( + CommandInfo::new( + strings::commands::close_popup(&self.key_config), + true, + true, + ) + .order(1), + ); + + out.push( + CommandInfo::new( + strings::commands::navigate_tree( + &self.key_config, + ), + true, + true, + ) + .order(1), + ); + + out.push( + CommandInfo::new( + strings::commands::toggle_option( + &self.key_config, + ), + self.option_selected(), + true, + ) + .order(1), + ); + + out.push(CommandInfo::new( + strings::commands::confirm_action(&self.key_config), + !self.find_text.get_text().trim().is_empty(), + self.visible, + )); + } + + visibility_blocking(self) + } + + fn event( + &mut self, + event: &crossterm::event::Event, + ) -> Result { + if self.is_visible() { + if let Event::Key(key) = &event { + if key_match(key, self.key_config.keys.exit_popup) { + self.hide(); + } else if key_match(key, self.key_config.keys.enter) + && !self.find_text.get_text().trim().is_empty() + { + self.execute_search(); + } else if key_match(key, self.key_config.keys.move_up) + { + self.move_selection(true); + } else if key_match( + key, + self.key_config.keys.move_down, + ) { + self.move_selection(false); + } else if key_match( + key, + self.key_config.keys.log_mark_commit, + ) && self.option_selected() + { + self.toggle_option(); + } + } + + if !self.option_selected() + && self.find_text.event(event)?.is_consumed() + { + return Ok(EventState::Consumed); + } + + 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(()) + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 14498adc..88a1a9a8 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -15,6 +15,7 @@ mod file_revlog; mod fuzzy_find_popup; mod help; mod inspect_commit; +mod log_search_popup; mod msg; mod options_popup; mod pull; @@ -51,6 +52,7 @@ pub use file_revlog::{FileRevOpen, FileRevlogComponent}; pub use fuzzy_find_popup::FuzzyFindPopup; pub use help::HelpComponent; pub use inspect_commit::{InspectCommitComponent, InspectCommitOpen}; +pub use log_search_popup::LogSearchPopupComponent; pub use msg::MsgComponent; pub use options_popup::{AppOption, OptionsPopupComponent}; pub use pull::PullComponent; diff --git a/src/components/utils/logitems.rs b/src/components/utils/logitems.rs index 31c07bf0..8689393d 100644 --- a/src/components/utils/logitems.rs +++ b/src/components/utils/logitems.rs @@ -1,6 +1,6 @@ use asyncgit::sync::{CommitId, CommitInfo}; use chrono::{DateTime, Duration, Local, NaiveDateTime, Utc}; -use std::slice::Iter; +use std::{collections::HashSet, slice::Iter}; #[cfg(feature = "ghemoji")] use super::emoji::emojifi_string; @@ -18,6 +18,7 @@ pub struct LogEntry { //TODO: use tinyvec here pub hash_short: BoxStr, pub id: CommitId, + pub highlighted: bool, } impl From for LogEntry { @@ -49,6 +50,7 @@ impl From for LogEntry { time, hash_short, id: c.id, + highlighted: false, } } } @@ -76,6 +78,7 @@ impl LogEntry { pub struct ItemBatch { index_offset: usize, items: Vec, + highlighting: bool, } impl ItemBatch { @@ -88,6 +91,11 @@ impl ItemBatch { self.index_offset } + /// + pub const fn highlighting(&self) -> bool { + self.highlighting + } + /// shortcut to get an `Iter` of our internal items pub fn iter(&self) -> Iter<'_, LogEntry> { self.items.iter() @@ -103,9 +111,24 @@ impl ItemBatch { &mut self, start_index: usize, commits: Vec, + highlighted: &Option>, ) { + log::debug!("highlighted: {:?}", highlighted); + self.items.clear(); - self.items.extend(commits.into_iter().map(LogEntry::from)); + self.items.extend(commits.into_iter().map(|c| { + let id = c.id; + let mut entry = LogEntry::from(c); + if highlighted + .as_ref() + .map(|highlighted| highlighted.contains(&id)) + .unwrap_or_default() + { + entry.highlighted = true; + } + entry + })); + self.highlighting = highlighted.is_some(); self.index_offset = start_index; } diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 619dde5e..edee24b7 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -87,6 +87,7 @@ pub struct KeysList { pub log_checkout_commit: GituiKeyEvent, pub log_reset_comit: GituiKeyEvent, pub log_reword_comit: GituiKeyEvent, + pub log_find: GituiKeyEvent, pub commit_amend: GituiKeyEvent, pub toggle_signoff: GituiKeyEvent, pub toggle_verify: GituiKeyEvent, @@ -174,6 +175,7 @@ impl Default for KeysList { log_checkout_commit: GituiKeyEvent { code: KeyCode::Char('S'), modifiers: KeyModifiers::SHIFT }, log_reset_comit: GituiKeyEvent { code: KeyCode::Char('R'), modifiers: KeyModifiers::SHIFT }, log_reword_comit: GituiKeyEvent { code: KeyCode::Char('r'), modifiers: KeyModifiers::empty() }, + log_find: GituiKeyEvent { code: KeyCode::Char('f'), modifiers: KeyModifiers::empty() }, commit_amend: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), toggle_signoff: GituiKeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), toggle_verify: GituiKeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), diff --git a/src/queue.rs b/src/queue.rs index c815ce65..3de68318 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -6,7 +6,9 @@ use crate::{ tabs::StashingOptions, }; use asyncgit::{ - sync::{diff::DiffLinePosition, CommitId}, + sync::{ + diff::DiffLinePosition, CommitId, LogFilterSearchOptions, + }, PushType, }; use bitflags::bitflags; @@ -113,6 +115,8 @@ pub enum InternalEvent { /// OpenFuzzyFinder(Vec, FuzzyFinderTarget), /// + OpenLogSearchPopup, + /// FuzzyFinderChanged(usize, String, FuzzyFinderTarget), /// FetchRemotes, @@ -130,6 +134,8 @@ pub enum InternalEvent { OpenResetPopup(CommitId), /// RewordCommit(CommitId), + /// + CommitSearch(LogFilterSearchOptions), } /// single threaded simple queue for components to communicate with each other diff --git a/src/strings.rs b/src/strings.rs index d9ca293b..dc689d64 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -7,8 +7,10 @@ use unicode_width::UnicodeWidthStr; use crate::keys::SharedKeyConfig; pub mod order { - pub static NAV: i8 = 2; - pub static RARE_ACTION: i8 = 1; + pub const RARE_ACTION: i8 = 30; + pub const NAV: i8 = 20; + pub const AVERAGE: i8 = 10; + pub const PRIORITY: i8 = 1; } pub static PUSH_POPUP_MSG: &str = "Push"; @@ -29,6 +31,7 @@ pub static PUSH_TAGS_STATES_DONE: &str = "done"; pub static POPUP_TITLE_SUBMODULES: &str = "Submodules"; pub static POPUP_TITLE_FUZZY_FIND: &str = "Fuzzy Finder"; +pub static POPUP_TITLE_LOG_SEARCH: &str = "Search"; pub static POPUP_FAIL_COPY: &str = "Failed to copy text"; pub static POPUP_SUCCESS_COPY: &str = "Copied Text"; @@ -592,6 +595,18 @@ pub mod commands { CMD_GROUP_LOG, ) } + pub fn toggle_option( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Toggle Option [{}]", + key_config.get_hint(key_config.keys.log_mark_commit), + ), + "toggle search option selected", + CMD_GROUP_LOG, + ) + } pub fn show_tag_annotation( key_config: &SharedKeyConfig, ) -> CommandText { @@ -1341,6 +1356,19 @@ pub mod commands { CMD_GROUP_LOG, ) } + pub fn log_close_search( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Exit Search [{}]", + key_config.get_hint(key_config.keys.exit_popup), + ), + "leave search mode", + CMD_GROUP_LOG, + ) + } + pub fn reset_commit(key_config: &SharedKeyConfig) -> CommandText { CommandText::new( format!( diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index 73ecef0d..05590c46 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -7,13 +7,17 @@ use crate::{ }, keys::{key_match, SharedKeyConfig}, queue::{InternalEvent, Queue, StackablePopupOpen}, - strings, try_or_popup, - ui::style::SharedTheme, + strings::{self, order}, + try_or_popup, + ui::style::{SharedTheme, Theme}, }; use anyhow::Result; use asyncgit::{ asyncjob::AsyncSingleJob, - sync::{self, CommitId, RepoPathRef}, + sync::{ + self, filter_commit_by_search, CommitId, LogFilterSearch, + LogFilterSearchOptions, RepoPathRef, + }, AsyncBranchesJob, AsyncGitNotification, AsyncLog, AsyncTags, CommitFilesParams, FetchStatus, }; @@ -21,26 +25,44 @@ use crossbeam_channel::Sender; use crossterm::event::Event; use ratatui::{ backend::Backend, - layout::{Constraint, Direction, Layout, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + text::Span, + widgets::{Block, Borders, Paragraph}, Frame, }; -use std::time::Duration; +use std::{collections::HashSet, rc::Rc, time::Duration}; use sync::CommitTags; const SLICE_SIZE: usize = 1200; +struct LogSearchResult { + commits: Vec, + options: LogFilterSearchOptions, + duration: Duration, +} + +//TODO: deserves its on component +enum LogSearch { + Off, + Searching(AsyncLog, LogFilterSearchOptions), + Results(LogSearchResult), +} + /// pub struct Revlog { repo: RepoPathRef, commit_details: CommitDetailsComponent, list: CommitList, git_log: AsyncLog, + search: LogSearch, git_tags: AsyncTags, git_local_branches: AsyncSingleJob, git_remote_branches: AsyncSingleJob, queue: Queue, visible: bool, key_config: SharedKeyConfig, + sender: Sender, + theme: SharedTheme, } impl Revlog { @@ -65,7 +87,7 @@ impl Revlog { list: CommitList::new( repo.clone(), &strings::log_title(&key_config), - theme, + theme.clone(), queue.clone(), key_config.clone(), ), @@ -74,34 +96,45 @@ impl Revlog { sender, None, ), + search: LogSearch::Off, git_tags: AsyncTags::new(repo.borrow().clone(), sender), git_local_branches: AsyncSingleJob::new(sender.clone()), git_remote_branches: AsyncSingleJob::new(sender.clone()), visible: false, key_config, + sender: sender.clone(), + theme, } } /// pub fn any_work_pending(&self) -> bool { self.git_log.is_pending() + || self.is_search_pending() || self.git_tags.is_pending() || self.git_local_branches.is_pending() || self.git_remote_branches.is_pending() || self.commit_details.any_work_pending() } + const fn is_search_pending(&self) -> bool { + matches!(self.search, LogSearch::Searching(_, _)) + } + /// pub fn update(&mut self) -> Result<()> { if self.is_visible() { let log_changed = self.git_log.fetch()? == FetchStatus::Started; + let search_changed = self.update_search_state()?; + let log_changed = log_changed || search_changed; + self.list.set_count_total(self.git_log.count()?); let selection = self.list.selection(); let selection_max = self.list.selection_max(); - if self.list.items().needs_data(selection, selection_max) + if self.list.needs_data(selection, selection_max) || log_changed { self.fetch_commits()?; @@ -184,7 +217,8 @@ impl Revlog { ); if let Ok(commits) = commits { - self.list.items().set_items(want_min, commits); + let highlighted = self.search_result_set(); + self.list.set_items(want_min, commits, &highlighted); } Ok(()) @@ -236,6 +270,117 @@ impl Revlog { )); } } + + pub fn search( + &mut self, + options: LogFilterSearchOptions, + ) -> Result<()> { + if matches!( + self.search, + LogSearch::Off | LogSearch::Results(_) + ) { + log::info!("start search: {:?}", options); + + let filter = filter_commit_by_search( + LogFilterSearch::new(options.clone()), + ); + + let mut async_find = AsyncLog::new( + self.repo.borrow().clone(), + &self.sender, + Some(filter), + ); + + async_find.fetch()?; + + self.search = LogSearch::Searching(async_find, options); + + self.fetch_commits()?; + } + + Ok(()) + } + + fn search_result_set(&self) -> Option> { + if let LogSearch::Results(results) = &self.search { + Some( + results + .commits + .iter() + .map(CommitId::clone) + .collect::>(), + ) + } else { + None + } + } + + fn update_search_state(&mut self) -> Result { + let changes = match &self.search { + LogSearch::Off | LogSearch::Results(_) => false, + LogSearch::Searching(search, options) => { + if search.is_pending() { + false + } else { + let results = search.get_items()?; + let duration = search.get_last_duration()?; + self.search = + LogSearch::Results(LogSearchResult { + commits: results, + options: options.clone(), + duration, + }); + true + } + } + }; + + Ok(changes) + } + + fn is_in_search_mode(&self) -> bool { + !matches!(self.search, LogSearch::Off) + } + + //TODO: draw time a search took + fn draw_search(&self, f: &mut Frame, area: Rect) { + let text = match &self.search { + LogSearch::Searching(_, options) => { + format!( + "'{}' (pending results...)", + options.search_pattern.clone() + ) + } + LogSearch::Results(results) => { + format!( + "'{}' (hits: {}) (duration: {:?})", + results.options.search_pattern.clone(), + results.commits.len(), + results.duration, + ) + } + LogSearch::Off => String::new(), + }; + + f.render_widget( + Paragraph::new(text) + .block( + Block::default() + .title(Span::styled( + strings::POPUP_TITLE_LOG_SEARCH, + self.theme.title(true), + )) + .borders(Borders::ALL) + .border_style(Theme::attention_block()), + ) + .alignment(Alignment::Left), + area, + ); + } + + fn can_leave_search(&self) -> bool { + self.is_in_search_mode() && !self.is_search_pending() + } } impl DrawableComponent for Revlog { @@ -244,6 +389,18 @@ impl DrawableComponent for Revlog { f: &mut Frame, area: Rect, ) -> Result<()> { + let area = if self.is_in_search_mode() { + Layout::default() + .direction(Direction::Vertical) + .constraints( + [Constraint::Min(1), Constraint::Length(3)] + .as_ref(), + ) + .split(area) + } else { + Rc::new([area]) + }; + let chunks = Layout::default() .direction(Direction::Horizontal) .constraints( @@ -253,13 +410,17 @@ impl DrawableComponent for Revlog { ] .as_ref(), ) - .split(area); + .split(area[0]); if self.commit_details.is_visible() { self.list.draw(f, chunks[0])?; self.commit_details.draw(f, chunks[1])?; } else { - self.list.draw(f, area)?; + self.list.draw(f, area[0])?; + } + + if self.is_in_search_mode() { + self.draw_search(f, area[1]); } Ok(()) @@ -281,6 +442,15 @@ impl Component for Revlog { self.commit_details.toggle_visible()?; self.update()?; return Ok(EventState::Consumed); + } else if key_match( + k, + self.key_config.keys.exit_popup, + ) { + if self.can_leave_search() { + self.search = LogSearch::Off; + self.fetch_commits()?; + return Ok(EventState::Consumed); + } } else if key_match(k, self.key_config.keys.copy) { try_or_popup!( self, @@ -373,6 +543,11 @@ impl Component for Revlog { Ok(EventState::Consumed) }, ); + } else if key_match(k, self.key_config.keys.log_find) + { + self.queue + .push(InternalEvent::OpenLogSearchPopup); + return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.compare_commits, @@ -418,6 +593,16 @@ impl Component for Revlog { self.list.commands(out, force_all); } + out.push( + CommandInfo::new( + strings::commands::log_close_search(&self.key_config), + true, + (self.visible && self.can_leave_search()) + || force_all, + ) + .order(order::PRIORITY), + ); + out.push(CommandInfo::new( strings::commands::log_details_toggle(&self.key_config), true, @@ -516,6 +701,10 @@ impl Component for Revlog { fn hide(&mut self) { self.visible = false; self.git_log.set_background(); + //TODO: + // self.git_log_find + // .as_mut() + // .map(asyncgit::AsyncLog::set_background); } fn show(&mut self) -> Result<()> { diff --git a/src/tabs/stashlist.rs b/src/tabs/stashlist.rs index c2eb1d86..1ac691b0 100644 --- a/src/tabs/stashlist.rs +++ b/src/tabs/stashlist.rs @@ -55,7 +55,7 @@ impl StashList { )?; self.list.set_count_total(commits.len()); - self.list.items().set_items(0, commits); + self.list.set_items(0, commits, &None); } Ok(()) diff --git a/src/tabs/status.rs b/src/tabs/status.rs index ec49cdc1..f59ca940 100644 --- a/src/tabs/status.rs +++ b/src/tabs/status.rs @@ -10,7 +10,7 @@ use crate::{ options::SharedOptions, queue::{Action, InternalEvent, NeedsUpdate, Queue, ResetItem}, strings, try_or_popup, - ui::style::SharedTheme, + ui::style::{SharedTheme, Theme}, }; use anyhow::Result; use asyncgit::{ @@ -313,9 +313,7 @@ impl Status { Block::default() .border_type(BorderType::Plain) .borders(Borders::all()) - .border_style( - Style::default().fg(Color::Yellow), - ) + .border_style(Theme::attention_block()) .title(format!( "Pending {:?}", self.git_state diff --git a/src/ui/style.rs b/src/ui/style.rs index de282910..fa570e33 100644 --- a/src/ui/style.rs +++ b/src/ui/style.rs @@ -212,6 +212,10 @@ impl Theme { ) } + pub fn commit_unhighlighted(&self) -> Style { + Style::default().fg(self.disabled_fg) + } + pub fn log_marker(&self, selected: bool) -> Style { let mut style = Style::default() .fg(self.commit_author) @@ -255,6 +259,10 @@ impl Theme { .bg(self.push_gauge_bg) } + pub fn attention_block() -> Style { + Style::default().fg(Color::Yellow) + } + fn load_patch(theme_path: &PathBuf) -> Result { let file = File::open(theme_path)?;