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

View file

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

View file

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

View file

@ -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<CommitId>,
}
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<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 {
@ -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<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.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 {

View file

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

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_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,
)
}
}