use crate::{ app::Environment, components::{ visibility_blocking, CommandBlocking, CommandInfo, CommitDetailsComponent, CommitList, Component, DrawableComponent, EventState, }, keys::{key_match, SharedKeyConfig}, popups::{FileTreeOpen, InspectCommitOpen}, queue::{InternalEvent, Queue, StackablePopupOpen}, strings::{self, order}, try_or_popup, ui::style::{SharedTheme, Theme}, }; use anyhow::Result; use asyncgit::{ asyncjob::AsyncSingleJob, sync::{ self, filter_commit_by_search, CommitId, LogFilterSearch, LogFilterSearchOptions, RepoPathRef, }, AsyncBranchesJob, AsyncCommitFilterJob, AsyncGitNotification, AsyncLog, AsyncTags, CommitFilesParams, FetchStatus, ProgressPercent, }; use crossbeam_channel::Sender; use crossterm::event::Event; use indexmap::IndexSet; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, text::Span, widgets::{Block, Borders, Paragraph}, Frame, }; use std::{ rc::Rc, sync::{ atomic::{AtomicBool, Ordering}, Arc, }, time::Duration, }; use sync::CommitTags; struct LogSearchResult { options: LogFilterSearchOptions, duration: Duration, } //TODO: deserves its own component enum LogSearch { Off, Searching( AsyncSingleJob, LogFilterSearchOptions, Option, Arc, ), Results(LogSearchResult), } /// pub struct Revlog { repo: RepoPathRef, commit_details: CommitDetailsComponent, list: CommitList, git_log: AsyncLog, search: LogSearch, git_tags: AsyncTags, git_local_branches: AsyncSingleJob, git_remote_branches: AsyncSingleJob, queue: Queue, visible: bool, key_config: SharedKeyConfig, sender: Sender, theme: SharedTheme, } impl Revlog { /// pub fn new(env: &Environment) -> Self { Self { repo: env.repo.clone(), queue: env.queue.clone(), commit_details: CommitDetailsComponent::new(env), list: CommitList::new( env, &strings::log_title(&env.key_config), ), git_log: AsyncLog::new( env.repo.borrow().clone(), &env.sender_git, None, ), search: LogSearch::Off, git_tags: AsyncTags::new( env.repo.borrow().clone(), &env.sender_git, ), git_local_branches: AsyncSingleJob::new( env.sender_git.clone(), ), git_remote_branches: AsyncSingleJob::new( env.sender_git.clone(), ), visible: false, key_config: env.key_config.clone(), sender: env.sender_git.clone(), theme: env.theme.clone(), } } /// 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() { if self.git_log.fetch()? == FetchStatus::Started { self.list.clear(); } self.list .refresh_extend_data(self.git_log.extract_items()?); self.git_tags.request(Duration::from_secs(3), false)?; if self.commit_details.is_visible() { let commit = self.selected_commit(); let tags = self.selected_commit_tags(&commit); self.commit_details.set_commits( commit.map(CommitFilesParams::from), &tags, )?; } } Ok(()) } /// pub fn update_git( &mut self, ev: AsyncGitNotification, ) -> Result<()> { if self.visible { match ev { AsyncGitNotification::CommitFiles | AsyncGitNotification::Log => self.update()?, AsyncGitNotification::CommitFilter => { self.update_search_state(); } AsyncGitNotification::Tags => { if let Some(tags) = self.git_tags.last()? { self.list.set_tags(tags); self.update()?; } } AsyncGitNotification::Branches => { if let Some(local_branches) = self.git_local_branches.take_last() { if let Some(Ok(local_branches)) = local_branches.result() { self.list .set_local_branches(local_branches); self.update()?; } } if let Some(remote_branches) = self.git_remote_branches.take_last() { if let Some(Ok(remote_branches)) = remote_branches.result() { self.list .set_remote_branches(remote_branches); self.update()?; } } } _ => (), } } Ok(()) } fn selected_commit(&self) -> Option { self.list.selected_entry().map(|e| e.id) } fn selected_commit_tags( &self, commit: &Option, ) -> Option { let tags = self.list.tags(); commit.and_then(|commit| { tags.and_then(|tags| tags.get(&commit).cloned()) }) } /// pub fn select_commit(&mut self, id: CommitId) -> Result<()> { self.list.select_commit(id) } fn revert_commit(&self) -> Result<()> { if let Some(c) = self.selected_commit() { sync::revert_commit(&self.repo.borrow(), c)?; self.queue.push(InternalEvent::TabSwitchStatus); } Ok(()) } fn inspect_commit(&self) { if let Some(commit_id) = self.selected_commit() { let tags = self.selected_commit_tags(&Some(commit_id)); self.queue.push(InternalEvent::OpenPopup( StackablePopupOpen::InspectCommit( InspectCommitOpen::new_with_tags(commit_id, tags), ), )); } } pub fn search(&mut self, options: LogFilterSearchOptions) { if !self.can_start_search() { return; } if matches!( self.search, LogSearch::Off | LogSearch::Results(_) ) { log::info!("start search: {:?}", options); let filter = filter_commit_by_search( LogFilterSearch::new(options.clone()), ); let cancellation_flag = Arc::new(AtomicBool::new(false)); let job = AsyncSingleJob::new(self.sender.clone()); job.spawn(AsyncCommitFilterJob::new( self.repo.borrow().clone(), self.list.copy_items(), filter, Arc::clone(&cancellation_flag), )); self.search = LogSearch::Searching( job, options, None, Arc::clone(&cancellation_flag), ); self.list.set_highlighting(None); } } fn cancel_search(&mut self) -> bool { if let LogSearch::Searching(_, _, _, cancellation_flag) = &self.search { cancellation_flag.store(true, Ordering::Relaxed); self.list.set_highlighting(None); return true; } false } fn update_search_state(&mut self) { match &mut self.search { LogSearch::Off | LogSearch::Results(_) => (), LogSearch::Searching( search, options, progress, cancel, ) => { if search.is_pending() { //update progress *progress = search.progress(); } else if let Some(search) = search .take_last() .and_then(|search| search.result()) { match search { Ok(search) => { let was_aborted = cancel.load(Ordering::Relaxed); self.search = if was_aborted { LogSearch::Off } else { self.list.set_highlighting(Some( Rc::new( search .result .into_iter() .collect::>(), ), )); LogSearch::Results(LogSearchResult { options: options.clone(), duration: search.duration, }) }; } Err(err) => { self.queue.push( InternalEvent::ShowErrorMsg(format!( "search error: {err}", )), ); self.search = LogSearch::Off; } } } } } } const fn is_in_search_mode(&self) -> bool { !matches!(self.search, LogSearch::Off) } fn draw_search(&self, f: &mut Frame, area: Rect) { let (text, title) = match &self.search { LogSearch::Searching(_, options, progress, _) => ( format!("'{}'", options.search_pattern.clone()), format!( "({}%)", progress .map(|progress| progress.progress) .unwrap_or_default() ), ), LogSearch::Results(results) => { let info = self.list.highlighted_selection_info(); ( format!( "'{}' (duration: {:?})", results.options.search_pattern.clone(), results.duration, ), format!( "({}/{})", (info.0 + 1).min(info.1), info.1 ), ) } LogSearch::Off => (String::new(), String::new()), }; f.render_widget( Paragraph::new(text) .block( Block::default() .title(Span::styled( format!( "{} {}", strings::POPUP_TITLE_LOG_SEARCH, title ), self.theme.title(true), )) .borders(Borders::ALL) .border_style(Theme::attention_block()), ) .alignment(Alignment::Left), area, ); } const fn can_close_search(&self) -> bool { self.is_in_search_mode() && !self.is_search_pending() } fn can_start_search(&self) -> bool { !self.git_log.is_pending() && !self.is_search_pending() } } impl DrawableComponent for Revlog { fn draw(&self, f: &mut Frame, area: Rect) -> Result<()> { let area = if self.is_in_search_mode() { Layout::default() .direction(Direction::Vertical) .constraints( [Constraint::Min(1), Constraint::Length(3)] .as_ref(), ) .split(area) } else { Rc::new([area]) }; let chunks = Layout::default() .direction(Direction::Horizontal) .constraints( [ Constraint::Percentage(60), Constraint::Percentage(40), ] .as_ref(), ) .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[0])?; } if self.is_in_search_mode() { self.draw_search(f, area[1]); } Ok(()) } } impl Component for Revlog { //TODO: cleanup #[allow(clippy::too_many_lines)] fn event(&mut self, ev: &Event) -> Result { if self.visible { let event_used = self.list.event(ev)?; if event_used.is_consumed() { self.update()?; return Ok(EventState::Consumed); } else if let Event::Key(k) = ev { if key_match(k, self.key_config.keys.enter) { self.commit_details.toggle_visible()?; self.update()?; return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.exit_popup, ) { if self.is_search_pending() { self.cancel_search(); } else if self.can_close_search() { self.list.set_highlighting(None); self.search = LogSearch::Off; } return Ok(EventState::Consumed); } else if key_match(k, self.key_config.keys.copy) { try_or_popup!( self, strings::POPUP_FAIL_COPY, self.list.copy_commit_hash() ); return Ok(EventState::Consumed); } else if key_match(k, self.key_config.keys.push) { self.queue.push(InternalEvent::PushTags); return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.log_tag_commit, ) { return self.selected_commit().map_or( Ok(EventState::NotConsumed), |id| { self.queue .push(InternalEvent::TagCommit(id)); Ok(EventState::Consumed) }, ); } else if key_match( k, self.key_config.keys.move_right, ) && self.commit_details.is_visible() { self.inspect_commit(); return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.select_branch, ) && !self.is_search_pending() { self.queue.push(InternalEvent::SelectBranch); return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.status_reset_item, ) && !self.is_search_pending() { try_or_popup!( self, "revert error:", self.revert_commit() ); return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.open_file_tree, ) && !self.is_search_pending() { return self.selected_commit().map_or( Ok(EventState::NotConsumed), |id| { self.queue.push( InternalEvent::OpenPopup( StackablePopupOpen::FileTree( FileTreeOpen::new(id), ), ), ); Ok(EventState::Consumed) }, ); } else if key_match(k, self.key_config.keys.tags) { self.queue.push(InternalEvent::Tags); return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.log_reset_commit, ) && !self.is_search_pending() { return self.selected_commit().map_or( Ok(EventState::NotConsumed), |id| { self.queue.push( InternalEvent::OpenResetPopup(id), ); Ok(EventState::Consumed) }, ); } else if key_match( k, self.key_config.keys.log_reword_commit, ) && !self.is_search_pending() { return self.selected_commit().map_or( Ok(EventState::NotConsumed), |id| { self.queue.push( InternalEvent::RewordCommit(id), ); Ok(EventState::Consumed) }, ); } else if key_match(k, self.key_config.keys.log_find) && self.can_start_search() { self.queue .push(InternalEvent::OpenLogSearchPopup); return Ok(EventState::Consumed); } else if key_match( k, self.key_config.keys.compare_commits, ) && self.list.marked_count() > 0 && !self.is_search_pending() { if self.list.marked_count() == 1 { // compare against head self.queue.push(InternalEvent::OpenPopup( StackablePopupOpen::CompareCommits( InspectCommitOpen::new( self.list.marked()[0].1, ), ), )); return Ok(EventState::Consumed); } else if self.list.marked_count() == 2 { //compare two marked commits let marked = self.list.marked(); self.queue.push(InternalEvent::OpenPopup( StackablePopupOpen::CompareCommits( InspectCommitOpen { commit_id: marked[0].1, compare_id: Some(marked[1].1), tags: None, }, ), )); return Ok(EventState::Consumed); } } } } Ok(EventState::NotConsumed) } fn commands( &self, out: &mut Vec, force_all: bool, ) -> CommandBlocking { if self.visible || force_all { self.list.commands(out, force_all); } out.push( CommandInfo::new( strings::commands::log_close_search(&self.key_config), true, (self.visible && (self.can_close_search() || self.is_search_pending())) || force_all, ) .order(order::PRIORITY), ); out.push(CommandInfo::new( strings::commands::log_details_toggle(&self.key_config), true, self.visible, )); out.push(CommandInfo::new( strings::commands::commit_details_open(&self.key_config), true, (self.visible && self.commit_details.is_visible()) || force_all, )); out.push(CommandInfo::new( strings::commands::open_branch_select_popup( &self.key_config, ), true, (self.visible && !self.is_search_pending()) || force_all, )); out.push(CommandInfo::new( strings::commands::compare_with_head(&self.key_config), self.list.marked_count() == 1, (self.visible && !self.is_search_pending() && self.list.marked_count() <= 1) || force_all, )); out.push(CommandInfo::new( strings::commands::compare_commits(&self.key_config), true, (self.visible && !self.is_search_pending() && self.list.marked_count() == 2) || force_all, )); out.push(CommandInfo::new( strings::commands::copy_hash(&self.key_config), self.selected_commit().is_some(), self.visible || force_all, )); out.push(CommandInfo::new( strings::commands::log_tag_commit(&self.key_config), self.selected_commit().is_some(), self.visible || force_all, )); out.push(CommandInfo::new( strings::commands::log_checkout_commit(&self.key_config), self.selected_commit().is_some(), self.visible || force_all, )); out.push(CommandInfo::new( strings::commands::open_tags_popup(&self.key_config), true, self.visible || force_all, )); out.push(CommandInfo::new( strings::commands::push_tags(&self.key_config), true, self.visible || force_all, )); out.push(CommandInfo::new( strings::commands::inspect_file_tree(&self.key_config), self.selected_commit().is_some(), (self.visible && !self.is_search_pending()) || force_all, )); out.push(CommandInfo::new( strings::commands::revert_commit(&self.key_config), self.selected_commit().is_some(), (self.visible && !self.is_search_pending()) || force_all, )); out.push(CommandInfo::new( strings::commands::log_reset_commit(&self.key_config), self.selected_commit().is_some(), (self.visible && !self.is_search_pending()) || force_all, )); out.push(CommandInfo::new( strings::commands::log_reword_commit(&self.key_config), self.selected_commit().is_some(), (self.visible && !self.is_search_pending()) || force_all, )); out.push(CommandInfo::new( strings::commands::log_find_commit(&self.key_config), self.can_start_search(), self.visible || force_all, )); visibility_blocking(self) } fn is_visible(&self) -> bool { self.visible } fn hide(&mut self) { self.visible = false; self.git_log.set_background(); } fn show(&mut self) -> Result<()> { self.visible = true; self.git_local_branches.spawn(AsyncBranchesJob::new( self.repo.borrow().clone(), true, )); self.git_remote_branches.spawn(AsyncBranchesJob::new( self.repo.borrow().clone(), false, )); self.update()?; Ok(()) } }