From c68fa3e87b75d2a2b566faffbfcca77de28240be Mon Sep 17 00:00:00 2001 From: Ammar Abou Zor <35040531+AmmarAbouZor@users.noreply.github.com> Date: Sun, 27 Aug 2023 09:46:41 +0200 Subject: [PATCH] Jump to commit via sha (#1818) --- CHANGELOG.md | 1 + asyncgit/src/sync/commits_info.rs | 43 ++- src/app.rs | 1 + src/components/log_search_popup.rs | 413 +++++++++++++++++++++-------- src/keys/key_list.rs | 2 + src/strings.rs | 14 + 6 files changed, 366 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3096b2162c..eef5280456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * added to [anaconda](https://anaconda.org/conda-forge/gitui) [[@TheBlackSheep3](https://github.com/TheBlackSheep3/)] ([#1626](https://github.com/extrawurst/gitui/issues/1626)) * visualize empty line substituted with content in diff better ([#1359](https://github.com/extrawurst/gitui/issues/1359)) * checkout branch works with non-empty status report [[@lightsnowball](https://github.com/lightsnowball)] ([#1399](https://github.com/extrawurst/gitui/issues/1399)) +* jump to commit by SHA [[@AmmarAbouZor](https://github.com/AmmarAbouZor)] ([#1818](https://github.com/extrawurst/gitui/pull/1818)) ### Fixes * fix commit dialog char count for multibyte characters ([#1726](https://github.com/extrawurst/gitui/issues/1726)) diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index b71ac3f177..1f049e9003 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -31,6 +31,19 @@ impl CommitId { pub fn get_short_string(&self) -> String { self.to_string().chars().take(7).collect() } + + /// Tries to retrieve the `CommitId` form the revision if exists in the given repository + pub fn from_revision( + repo_path: &RepoPath, + revision: &str, + ) -> Result { + scope_time!("CommitId::from_revision"); + + let repo = repo(repo_path)?; + + let commit_obj = repo.revparse_single(revision)?; + Ok(commit_obj.id().into()) + } } impl ToString for CommitId { @@ -144,7 +157,7 @@ mod tests { error::Result, sync::{ commit, stage_add_file, tests::repo_init_empty, - utils::get_head_repo, RepoPath, + utils::get_head_repo, CommitId, RepoPath, }, }; use std::{fs::File, io::Write, path::Path}; @@ -221,4 +234,32 @@ mod tests { Ok(()) } + + #[test] + fn test_get_commit_from_revision() -> Result<()> { + let (_td, repo) = repo_init_empty().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: &RepoPath = + &root.as_os_str().to_str().unwrap().into(); + + let foo_file = Path::new("foo"); + File::create(root.join(foo_file))?.write_all(b"a")?; + stage_add_file(repo_path, foo_file).unwrap(); + let c1 = commit(repo_path, "subject: foo\nbody").unwrap(); + let c1_rev = c1.get_short_string(); + + assert_eq!( + CommitId::from_revision(repo_path, c1_rev.as_str()) + .unwrap(), + c1 + ); + + const FOREIGN_HASH: &str = + "d6d7d55cb6e4ba7301d6a11a657aab4211e5777e"; + assert!( + CommitId::from_revision(repo_path, FOREIGN_HASH).is_err() + ); + + Ok(()) + } } diff --git a/src/app.rs b/src/app.rs index c60fec2725..eb626c9025 100644 --- a/src/app.rs +++ b/src/app.rs @@ -273,6 +273,7 @@ impl App { key_config.clone(), ), log_search_popup: LogSearchPopupComponent::new( + repo.clone(), &queue, theme.clone(), key_config.clone(), diff --git a/src/components/log_search_popup.rs b/src/components/log_search_popup.rs index 1d303dfe69..564a00978b 100644 --- a/src/components/log_search_popup.rs +++ b/src/components/log_search_popup.rs @@ -5,14 +5,16 @@ use super::{ use crate::{ keys::{key_match, SharedKeyConfig}, queue::{InternalEvent, Queue}, - strings::{self}, + strings::{self, POPUP_COMMIT_SHA_INVALID}, ui::{self, style::SharedTheme}, }; use anyhow::Result; use asyncgit::sync::{ - LogFilterSearchOptions, SearchFields, SearchOptions, + CommitId, LogFilterSearchOptions, RepoPathRef, SearchFields, + SearchOptions, }; use crossterm::event::Event; +use easy_cast::Cast; use ratatui::{ backend::Backend, layout::{ @@ -32,19 +34,28 @@ enum Selection { AuthorsSearch, } +enum PopupMode { + Search, + JumpCommitSha, +} + pub struct LogSearchPopupComponent { + repo: RepoPathRef, queue: Queue, visible: bool, + mode: PopupMode, selection: Selection, key_config: SharedKeyConfig, find_text: TextInputComponent, options: (SearchFields, SearchOptions), theme: SharedTheme, + jump_commit_id: Option, } impl LogSearchPopupComponent { /// pub fn new( + repo: RepoPathRef, queue: &Queue, theme: SharedTheme, key_config: SharedKeyConfig, @@ -60,8 +71,10 @@ impl LogSearchPopupComponent { find_text.enabled(true); Self { + repo, queue: queue.clone(), visible: false, + mode: PopupMode::Search, key_config, options: ( SearchFields::default(), @@ -70,6 +83,7 @@ impl LogSearchPopupComponent { theme, find_text, selection: Selection::EnterText, + jump_commit_id: None, } } @@ -80,23 +94,81 @@ impl LogSearchPopupComponent { self.find_text.set_text(String::new()); self.find_text.enabled(true); + self.set_mode(&PopupMode::Search); + Ok(()) } - fn execute_search(&mut self) { + fn set_mode(&mut self, mode: &PopupMode) { + self.find_text.set_text(String::new()); + + match mode { + PopupMode::Search => { + self.mode = PopupMode::Search; + self.find_text.set_default_msg("search text".into()); + self.find_text.enabled(matches!( + self.selection, + Selection::EnterText + )); + } + PopupMode::JumpCommitSha => { + self.mode = PopupMode::JumpCommitSha; + self.jump_commit_id = None; + self.find_text.set_default_msg("commit sha".into()); + self.find_text.enabled(false); + self.selection = Selection::EnterText; + } + } + } + + fn execute_confirm(&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(), - }, - )); + if !self.is_valid() { + return; + } + + match self.mode { + PopupMode::Search => { + self.queue.push(InternalEvent::CommitSearch( + LogFilterSearchOptions { + fields: self.options.0, + options: self.options.1, + search_pattern: self + .find_text + .get_text() + .to_string(), + }, + )); + } + PopupMode::JumpCommitSha => { + let commit_id = self.jump_commit_id + .expect("Commit id must have value here because it's already validated"); + self.queue.push(InternalEvent::SelectCommitInRevlog( + commit_id, + )); + } + } + } + + fn is_valid(&self) -> bool { + match self.mode { + PopupMode::Search => { + !self.find_text.get_text().trim().is_empty() + } + PopupMode::JumpCommitSha => self.jump_commit_id.is_some(), + } + } + + fn validate_commit_sha(&mut self) { + let path = self.repo.borrow(); + if let Ok(commit_id) = CommitId::from_revision( + &path, + self.find_text.get_text().trim(), + ) { + self.jump_commit_id = Some(commit_id); + } else { + self.jump_commit_id = None; } } @@ -255,6 +327,177 @@ impl LogSearchPopupComponent { self.find_text .enabled(matches!(self.selection, Selection::EnterText)); } + + fn draw_search_mode( + &self, + f: &mut Frame, + area: Rect, + ) -> Result<()> { + 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(()) + } + + fn draw_commit_sha_mode( + &self, + f: &mut Frame, + area: Rect, + ) -> Result<()> { + const SIZE: (u16, u16) = (60, 3); + let area = ui::centered_rect_absolute(SIZE.0, SIZE.1, area); + + let mut block_style = self.theme.title(true); + + let show_invalid = !self.is_valid() + && !self.find_text.get_text().trim().is_empty(); + + if show_invalid { + block_style = block_style.patch(self.theme.text_danger()); + } + + f.render_widget(Clear, area); + f.render_widget( + Block::default() + .borders(Borders::all()) + .style(block_style) + .title(Span::styled( + strings::POPUP_TITLE_LOG_SEARCH, + self.theme.title(true), + )), + area, + ); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1)].as_ref()) + .split(area.inner(&Margin { + horizontal: 1, + vertical: 1, + })); + + self.find_text.draw(f, chunks[0])?; + + if show_invalid { + self.draw_invalid_sha(f); + } + + Ok(()) + } + + fn draw_invalid_sha(&self, f: &mut Frame) { + let msg_length: u16 = POPUP_COMMIT_SHA_INVALID.len().cast(); + let w = Paragraph::new(POPUP_COMMIT_SHA_INVALID) + .style(self.theme.text_danger()); + + let rect = { + let mut rect = self.find_text.get_area(); + rect.y += rect.height; + rect.height = 1; + let offset = rect.width.saturating_sub(msg_length); + rect.width = rect.width.saturating_sub(offset); + rect.x += offset; + + rect + }; + + f.render_widget(w, rect); + } + + #[inline] + fn event_search_mode( + &mut self, + event: &crossterm::event::Event, + ) -> Result { + 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.is_valid() + { + self.execute_confirm(); + } else if key_match(key, self.key_config.keys.popup_up) { + self.move_selection(true); + } else if key_match( + key, + self.key_config.keys.find_commit_sha, + ) { + self.set_mode(&PopupMode::JumpCommitSha); + } else if key_match(key, self.key_config.keys.popup_down) + { + self.move_selection(false); + } else if key_match( + key, + self.key_config.keys.log_mark_commit, + ) && self.option_selected() + { + self.toggle_option(); + } else if !self.option_selected() { + self.find_text.event(event)?; + } + } + + Ok(EventState::Consumed) + } + + #[inline] + fn event_commit_sha_mode( + &mut self, + event: &crossterm::event::Event, + ) -> Result { + if let Event::Key(key) = &event { + if key_match(key, self.key_config.keys.exit_popup) { + self.set_mode(&PopupMode::Search); + } else if key_match(key, self.key_config.keys.enter) + && self.is_valid() + { + self.execute_confirm(); + } else if self.find_text.event(event)?.is_consumed() { + self.validate_commit_sha(); + self.find_text.enabled( + !self.find_text.get_text().trim().is_empty(), + ); + } + } + + Ok(EventState::Consumed) + } } impl DrawableComponent for LogSearchPopupComponent { @@ -264,48 +507,14 @@ impl DrawableComponent for LogSearchPopupComponent { 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], - ); + match self.mode { + PopupMode::Search => { + self.draw_search_mode(f, area)?; + } + PopupMode::JumpCommitSha => { + self.draw_commit_sha_mode(f, area)?; + } + } } Ok(()) @@ -328,29 +537,42 @@ impl Component for LogSearchPopupComponent { .order(1), ); - out.push( - CommandInfo::new( - strings::commands::scroll_popup(&self.key_config), - true, - true, - ) - .order(1), - ); - - out.push( - CommandInfo::new( - strings::commands::toggle_option( - &self.key_config, - ), - self.option_selected(), - true, - ) - .order(1), - ); + if matches!(self.mode, PopupMode::Search) { + out.push( + CommandInfo::new( + strings::commands::scroll_popup( + &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::find_commit_sha( + &self.key_config, + ), + true, + true, + ) + .order(1), + ); + } out.push(CommandInfo::new( strings::commands::confirm_action(&self.key_config), - !self.find_text.get_text().trim().is_empty(), + self.is_valid(), self.visible, )); } @@ -362,39 +584,16 @@ impl Component for LogSearchPopupComponent { &mut self, event: &crossterm::event::Event, ) -> Result { - if self.is_visible() { - if let Event::Key(key) = &event { - if key_match(key, self.key_config.keys.exit_popup) { - self.hide(); - } else if key_match(key, self.key_config.keys.enter) - && !self.find_text.get_text().trim().is_empty() - { - self.execute_search(); - } else if key_match( - key, - self.key_config.keys.popup_up, - ) { - self.move_selection(true); - } else if key_match( - key, - self.key_config.keys.popup_down, - ) { - self.move_selection(false); - } else if key_match( - key, - self.key_config.keys.log_mark_commit, - ) && self.option_selected() - { - self.toggle_option(); - } else if !self.option_selected() { - self.find_text.event(event)?; - } - } - - return Ok(EventState::Consumed); + if !self.is_visible() { + return Ok(EventState::NotConsumed); } - Ok(EventState::NotConsumed) + match self.mode { + PopupMode::Search => self.event_search_mode(event), + PopupMode::JumpCommitSha => { + self.event_commit_sha_mode(event) + } + } } fn is_visible(&self) -> bool { diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index edee24b7ea..5530719330 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -88,6 +88,7 @@ pub struct KeysList { pub log_reset_comit: GituiKeyEvent, pub log_reword_comit: GituiKeyEvent, pub log_find: GituiKeyEvent, + pub find_commit_sha: GituiKeyEvent, pub commit_amend: GituiKeyEvent, pub toggle_signoff: GituiKeyEvent, pub toggle_verify: GituiKeyEvent, @@ -176,6 +177,7 @@ impl Default for KeysList { 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() }, + find_commit_sha: GituiKeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), commit_amend: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), toggle_signoff: GituiKeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), toggle_verify: GituiKeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), diff --git a/src/strings.rs b/src/strings.rs index bb0e49330a..158c58b684 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -35,6 +35,7 @@ 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"; +pub static POPUP_COMMIT_SHA_INVALID: &str = "Invalid commit sha"; pub mod symbol { pub const WHITESPACE: &str = "\u{00B7}"; //ยท @@ -1672,4 +1673,17 @@ pub mod commands { CMD_GROUP_BRANCHES, ) } + + pub fn find_commit_sha( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Search Hash [{}]", + key_config.get_hint(key_config.keys.find_commit_sha), + ), + "find commit from sha", + CMD_GROUP_LOG, + ) + } }