commit log filtering (#1800)

This commit is contained in:
extrawurst 2023-08-18 17:19:18 +02:00 committed by GitHub
parent b50d44a4b8
commit 3c5131ad27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1139 additions and 107 deletions

View File

@ -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))

2
Cargo.lock generated
View File

@ -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",

View File

@ -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))

BIN
assets/log-search.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -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"]}

View File

@ -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<CommitId>,
///
pub duration: Duration,
}
///
pub struct AsyncLog {
current: Arc<Mutex<Vec<CommitId>>>,
current: Arc<Mutex<AsyncLogResult>>,
current_head: Arc<Mutex<Option<CommitId>>>,
sender: Sender<AsyncGitNotification>,
pending: Arc<AtomicBool>,
@ -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<usize> {
Ok(self.current.lock()?.len())
Ok(self.current.lock()?.commits.len())
}
///
@ -69,7 +79,7 @@ impl AsyncLog {
start_index: usize,
amount: usize,
) -> Result<Vec<CommitId>> {
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<Vec<CommitId>> {
let list = &self.current.lock()?.commits;
Ok(list.clone())
}
///
pub fn get_last_duration(&self) -> Result<Duration> {
Ok(self.current.lock()?.duration)
}
///
pub fn position(&self, id: CommitId) -> Result<Option<usize>> {
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<Mutex<Vec<CommitId>>>,
arc_current: &Arc<Mutex<AsyncLogResult>>,
arc_background: &Arc<AtomicBool>,
sender: &Sender<AsyncGitNotification>,
filter: Option<LogWalkerFilter>,
) -> 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(())
}

View File

@ -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<bool> {
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<TimeOrderedCommit<'a>>,
@ -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);
}
}

View File

@ -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,

View File

@ -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)

View File

@ -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,
}
}

View File

@ -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<bool> {
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<bool> {
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<bool> {
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<String>,
@ -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::<String>(
author,
theme.commit_author(selected),
));
txt.push(Span::styled::<String>(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<String> {
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<asyncgit::sync::CommitInfo>,
highlighted: &Option<HashSet<CommitId>>,
) {
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 {

View File

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

View File

@ -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<Line> {
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<B: Backend>(
&self,
f: &mut Frame<B>,
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<CommandInfo>,
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<EventState> {
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(())
}
}

View File

@ -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;

View File

@ -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<CommitInfo> for LogEntry {
@ -49,6 +50,7 @@ impl From<CommitInfo> for LogEntry {
time,
hash_short,
id: c.id,
highlighted: false,
}
}
}
@ -76,6 +78,7 @@ impl LogEntry {
pub struct ItemBatch {
index_offset: usize,
items: Vec<LogEntry>,
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<CommitInfo>,
highlighted: &Option<HashSet<CommitId>>,
) {
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;
}

View File

@ -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),

View File

@ -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<String>, 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

View File

@ -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!(

View File

@ -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<CommitId>,
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<AsyncBranchesJob>,
git_remote_branches: AsyncSingleJob<AsyncBranchesJob>,
queue: Queue,
visible: bool,
key_config: SharedKeyConfig,
sender: Sender<AsyncGitNotification>,
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<HashSet<CommitId>> {
if let LogSearch::Results(results) = &self.search {
Some(
results
.commits
.iter()
.map(CommitId::clone)
.collect::<HashSet<_>>(),
)
} else {
None
}
}
fn update_search_state(&mut self) -> Result<bool> {
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<B: Backend>(&self, f: &mut Frame<B>, 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<B>,
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<()> {

View File

@ -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(())

View File

@ -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

View File

@ -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<ThemePatch> {
let file = File::open(theme_path)?;