mirror of
https://github.com/extrawurst/gitui.git
synced 2024-11-22 02:12:58 +03:00
commit log filtering (#1800)
This commit is contained in:
parent
b50d44a4b8
commit
3c5131ad27
@ -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
2
Cargo.lock
generated
@ -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",
|
||||
|
@ -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
BIN
assets/log-search.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 MiB |
@ -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"]}
|
||||
|
@ -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(())
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
24
src/app.rs
24
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)
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
|
388
src/components/log_search_popup.rs
Normal file
388
src/components/log_search_popup.rs
Normal 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(())
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
|
@ -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!(
|
||||
|
@ -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<()> {
|
||||
|
@ -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(())
|
||||
|
@ -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
|
||||
|
@ -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)?;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user