Jump to commit via sha (#1818)

This commit is contained in:
Ammar Abou Zor 2023-08-27 09:46:41 +02:00 committed by GitHub
parent 005047f015
commit c68fa3e87b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 366 additions and 108 deletions

View file

@ -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)) * 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)) * 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)) * 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 ### Fixes
* fix commit dialog char count for multibyte characters ([#1726](https://github.com/extrawurst/gitui/issues/1726)) * fix commit dialog char count for multibyte characters ([#1726](https://github.com/extrawurst/gitui/issues/1726))

View file

@ -31,6 +31,19 @@ impl CommitId {
pub fn get_short_string(&self) -> String { pub fn get_short_string(&self) -> String {
self.to_string().chars().take(7).collect() 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<Self> {
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 { impl ToString for CommitId {
@ -144,7 +157,7 @@ mod tests {
error::Result, error::Result,
sync::{ sync::{
commit, stage_add_file, tests::repo_init_empty, 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}; use std::{fs::File, io::Write, path::Path};
@ -221,4 +234,32 @@ mod tests {
Ok(()) 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(())
}
} }

View file

@ -273,6 +273,7 @@ impl App {
key_config.clone(), key_config.clone(),
), ),
log_search_popup: LogSearchPopupComponent::new( log_search_popup: LogSearchPopupComponent::new(
repo.clone(),
&queue, &queue,
theme.clone(), theme.clone(),
key_config.clone(), key_config.clone(),

View file

@ -5,14 +5,16 @@ use super::{
use crate::{ use crate::{
keys::{key_match, SharedKeyConfig}, keys::{key_match, SharedKeyConfig},
queue::{InternalEvent, Queue}, queue::{InternalEvent, Queue},
strings::{self}, strings::{self, POPUP_COMMIT_SHA_INVALID},
ui::{self, style::SharedTheme}, ui::{self, style::SharedTheme},
}; };
use anyhow::Result; use anyhow::Result;
use asyncgit::sync::{ use asyncgit::sync::{
LogFilterSearchOptions, SearchFields, SearchOptions, CommitId, LogFilterSearchOptions, RepoPathRef, SearchFields,
SearchOptions,
}; };
use crossterm::event::Event; use crossterm::event::Event;
use easy_cast::Cast;
use ratatui::{ use ratatui::{
backend::Backend, backend::Backend,
layout::{ layout::{
@ -32,19 +34,28 @@ enum Selection {
AuthorsSearch, AuthorsSearch,
} }
enum PopupMode {
Search,
JumpCommitSha,
}
pub struct LogSearchPopupComponent { pub struct LogSearchPopupComponent {
repo: RepoPathRef,
queue: Queue, queue: Queue,
visible: bool, visible: bool,
mode: PopupMode,
selection: Selection, selection: Selection,
key_config: SharedKeyConfig, key_config: SharedKeyConfig,
find_text: TextInputComponent, find_text: TextInputComponent,
options: (SearchFields, SearchOptions), options: (SearchFields, SearchOptions),
theme: SharedTheme, theme: SharedTheme,
jump_commit_id: Option<CommitId>,
} }
impl LogSearchPopupComponent { impl LogSearchPopupComponent {
/// ///
pub fn new( pub fn new(
repo: RepoPathRef,
queue: &Queue, queue: &Queue,
theme: SharedTheme, theme: SharedTheme,
key_config: SharedKeyConfig, key_config: SharedKeyConfig,
@ -60,8 +71,10 @@ impl LogSearchPopupComponent {
find_text.enabled(true); find_text.enabled(true);
Self { Self {
repo,
queue: queue.clone(), queue: queue.clone(),
visible: false, visible: false,
mode: PopupMode::Search,
key_config, key_config,
options: ( options: (
SearchFields::default(), SearchFields::default(),
@ -70,6 +83,7 @@ impl LogSearchPopupComponent {
theme, theme,
find_text, find_text,
selection: Selection::EnterText, selection: Selection::EnterText,
jump_commit_id: None,
} }
} }
@ -80,23 +94,81 @@ impl LogSearchPopupComponent {
self.find_text.set_text(String::new()); self.find_text.set_text(String::new());
self.find_text.enabled(true); self.find_text.enabled(true);
self.set_mode(&PopupMode::Search);
Ok(()) 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(); self.hide();
if !self.find_text.get_text().trim().is_empty() { if !self.is_valid() {
self.queue.push(InternalEvent::CommitSearch( return;
LogFilterSearchOptions { }
fields: self.options.0,
options: self.options.1, match self.mode {
search_pattern: self PopupMode::Search => {
.find_text self.queue.push(InternalEvent::CommitSearch(
.get_text() LogFilterSearchOptions {
.to_string(), 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 self.find_text
.enabled(matches!(self.selection, Selection::EnterText)); .enabled(matches!(self.selection, Selection::EnterText));
} }
fn draw_search_mode<B: Backend>(
&self,
f: &mut Frame<B>,
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<B: Backend>(
&self,
f: &mut Frame<B>,
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<B: Backend>(&self, f: &mut Frame<B>) {
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<EventState> {
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<EventState> {
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 { impl DrawableComponent for LogSearchPopupComponent {
@ -264,48 +507,14 @@ impl DrawableComponent for LogSearchPopupComponent {
area: Rect, area: Rect,
) -> Result<()> { ) -> Result<()> {
if self.is_visible() { if self.is_visible() {
const SIZE: (u16, u16) = (60, 10); match self.mode {
let area = PopupMode::Search => {
ui::centered_rect_absolute(SIZE.0, SIZE.1, area); self.draw_search_mode(f, area)?;
}
f.render_widget(Clear, area); PopupMode::JumpCommitSha => {
f.render_widget( self.draw_commit_sha_mode(f, area)?;
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(()) Ok(())
@ -328,29 +537,42 @@ impl Component for LogSearchPopupComponent {
.order(1), .order(1),
); );
out.push( if matches!(self.mode, PopupMode::Search) {
CommandInfo::new( out.push(
strings::commands::scroll_popup(&self.key_config), CommandInfo::new(
true, strings::commands::scroll_popup(
true, &self.key_config,
) ),
.order(1), true,
); true,
)
out.push( .order(1),
CommandInfo::new( );
strings::commands::toggle_option( out.push(
&self.key_config, CommandInfo::new(
), strings::commands::toggle_option(
self.option_selected(), &self.key_config,
true, ),
) self.option_selected(),
.order(1), true,
); )
.order(1),
);
out.push(
CommandInfo::new(
strings::commands::find_commit_sha(
&self.key_config,
),
true,
true,
)
.order(1),
);
}
out.push(CommandInfo::new( out.push(CommandInfo::new(
strings::commands::confirm_action(&self.key_config), strings::commands::confirm_action(&self.key_config),
!self.find_text.get_text().trim().is_empty(), self.is_valid(),
self.visible, self.visible,
)); ));
} }
@ -362,39 +584,16 @@ impl Component for LogSearchPopupComponent {
&mut self, &mut self,
event: &crossterm::event::Event, event: &crossterm::event::Event,
) -> Result<EventState> { ) -> Result<EventState> {
if self.is_visible() { if !self.is_visible() {
if let Event::Key(key) = &event { return Ok(EventState::NotConsumed);
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);
} }
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 { fn is_visible(&self) -> bool {

View file

@ -88,6 +88,7 @@ pub struct KeysList {
pub log_reset_comit: GituiKeyEvent, pub log_reset_comit: GituiKeyEvent,
pub log_reword_comit: GituiKeyEvent, pub log_reword_comit: GituiKeyEvent,
pub log_find: GituiKeyEvent, pub log_find: GituiKeyEvent,
pub find_commit_sha: GituiKeyEvent,
pub commit_amend: GituiKeyEvent, pub commit_amend: GituiKeyEvent,
pub toggle_signoff: GituiKeyEvent, pub toggle_signoff: GituiKeyEvent,
pub toggle_verify: 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_reset_comit: GituiKeyEvent { code: KeyCode::Char('R'), modifiers: KeyModifiers::SHIFT },
log_reword_comit: GituiKeyEvent { code: KeyCode::Char('r'), modifiers: KeyModifiers::empty() }, log_reword_comit: GituiKeyEvent { code: KeyCode::Char('r'), modifiers: KeyModifiers::empty() },
log_find: GituiKeyEvent { code: KeyCode::Char('f'), 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), commit_amend: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
toggle_signoff: GituiKeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), toggle_signoff: GituiKeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL),
toggle_verify: GituiKeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), toggle_verify: GituiKeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),

View file

@ -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_FAIL_COPY: &str = "Failed to copy text";
pub static POPUP_SUCCESS_COPY: &str = "Copied Text"; pub static POPUP_SUCCESS_COPY: &str = "Copied Text";
pub static POPUP_COMMIT_SHA_INVALID: &str = "Invalid commit sha";
pub mod symbol { pub mod symbol {
pub const WHITESPACE: &str = "\u{00B7}"; //· pub const WHITESPACE: &str = "\u{00B7}"; //·
@ -1672,4 +1673,17 @@ pub mod commands {
CMD_GROUP_BRANCHES, 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,
)
}
} }