diff --git a/CHANGELOG.md b/CHANGELOG.md index 97dc8a7235..e5b377c997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * new command-line option to override the default log file path (`--logfile`) [[@acuteenvy](https://github.com/acuteenvy)] ([#2539](https://github.com/gitui-org/gitui/pull/2539)) * dx: `make check` checks Cargo.toml dependency ordering using `cargo sort` [[@naseschwarz](https://github.com/naseschwarz)] * add `use_selection_fg` to theme file to allow customizing selection foreground color [[@Upsylonbare](https://github.com/Upsylonbare)] ([#2515](https://github.com/gitui-org/gitui/pull/2515)) +* Add "go to line" command for the blame view [[@andrea-berling](https://github.com/andrea-berling)] ([#2262](https://github.com/extrawurst/gitui/pull/2262)) ### Changed * improve error messages [[@acuteenvy](https://github.com/acuteenvy)] ([#2617](https://github.com/gitui-org/gitui/pull/2617)) diff --git a/src/app.rs b/src/app.rs index 479e2868cf..68a1b439e9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,7 +13,7 @@ use crate::{ AppOption, BlameFilePopup, BranchListPopup, CommitPopup, CompareCommitsPopup, ConfirmPopup, CreateBranchPopup, CreateRemotePopup, ExternalEditorPopup, FetchPopup, - FileRevlogPopup, FuzzyFindPopup, HelpPopup, + FileRevlogPopup, FuzzyFindPopup, GotoLinePopup, HelpPopup, InspectCommitPopup, LogSearchPopupPopup, MsgPopup, OptionsPopup, PullPopup, PushPopup, PushTagsPopup, RemoteListPopup, RenameBranchPopup, RenameRemotePopup, @@ -112,6 +112,7 @@ pub struct App { popup_stack: PopupStack, options: SharedOptions, repo_path_text: String, + goto_line_popup: GotoLinePopup, // "Flags" requires_redraw: Cell, @@ -218,6 +219,7 @@ impl App { stashing_tab: Stashing::new(&env), stashlist_tab: StashList::new(&env), files_tab: FilesTab::new(&env), + goto_line_popup: GotoLinePopup::new(&env), tab: 0, queue: env.queue, theme: env.theme, @@ -481,6 +483,7 @@ impl App { msg_popup, confirm_popup, commit_popup, + goto_line_popup, blame_file_popup, file_revlog_popup, stashmsg_popup, @@ -544,7 +547,8 @@ impl App { fetch_popup, options_popup, confirm_popup, - msg_popup + msg_popup, + goto_line_popup ] ); @@ -905,6 +909,14 @@ impl App { InternalEvent::CommitSearch(options) => { self.revlog.search(options); } + InternalEvent::OpenGotoLinePopup(max_line) => { + self.goto_line_popup.open(max_line); + } + InternalEvent::GotoLine(line) => { + if self.blame_file_popup.is_visible() { + self.blame_file_popup.goto_line(line); + } + } } Ok(flags) diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 0f2909a2fe..24a9507a49 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -128,6 +128,7 @@ pub struct KeysList { pub commit_history_next: GituiKeyEvent, pub commit: GituiKeyEvent, pub newline: GituiKeyEvent, + pub goto_line: GituiKeyEvent, } #[rustfmt::skip] @@ -225,6 +226,7 @@ impl Default for KeysList { commit_history_next: GituiKeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), commit: GituiKeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), newline: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), + goto_line: GituiKeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT), } } } diff --git a/src/popups/blame_file.rs b/src/popups/blame_file.rs index f73168066a..b7d1304b33 100644 --- a/src/popups/blame_file.rs +++ b/src/popups/blame_file.rs @@ -234,6 +234,16 @@ impl Component for BlameFilePopup { ) .order(1), ); + out.push( + CommandInfo::new( + strings::commands::open_line_number_popup( + &self.key_config, + ), + true, + has_result, + ) + .order(1), + ); } visibility_blocking(self) @@ -307,6 +317,22 @@ impl Component for BlameFilePopup { ), )); } + } else if key_match( + key, + self.key_config.keys.goto_line, + ) { + let maybe_blame_result = &self + .blame + .as_ref() + .and_then(|blame| blame.result()); + if let Some(blame_result) = maybe_blame_result { + let max_line = blame_result.lines().len() - 1; + self.queue.push( + InternalEvent::OpenGotoLinePopup( + max_line, + ), + ); + } } return Ok(EventState::Consumed); @@ -742,6 +768,14 @@ impl BlameFilePopup { }) } + pub fn goto_line(&mut self, line: usize) { + self.visible = true; + let mut table_state = self.table_state.take(); + table_state + .select(Some(line.clamp(0, self.get_max_line_number()))); + self.table_state.set(table_state); + } + fn selected_commit(&self) -> Option { self.blame .as_ref() diff --git a/src/popups/goto_line.rs b/src/popups/goto_line.rs new file mode 100644 index 0000000000..492f60b779 --- /dev/null +++ b/src/popups/goto_line.rs @@ -0,0 +1,167 @@ +use crate::{ + app::Environment, + components::{ + visibility_blocking, CommandBlocking, CommandInfo, Component, + DrawableComponent, EventState, + }, + keys::{key_match, SharedKeyConfig}, + queue::{InternalEvent, Queue}, + strings, + ui::{self, style::SharedTheme}, +}; + +use ratatui::{ + layout::Rect, + style::{Color, Style}, + widgets::{Block, Clear, Paragraph}, + Frame, +}; + +use anyhow::Result; + +use crossterm::event::{Event, KeyCode}; + +pub struct GotoLinePopup { + visible: bool, + input: String, + line_number: usize, + key_config: SharedKeyConfig, + queue: Queue, + theme: SharedTheme, + invalid_input: bool, + max_line: usize, +} + +impl GotoLinePopup { + pub fn new(env: &Environment) -> Self { + Self { + visible: false, + input: String::new(), + key_config: env.key_config.clone(), + queue: env.queue.clone(), + theme: env.theme.clone(), + invalid_input: false, + max_line: 0, + line_number: 0, + } + } + + pub fn open(&mut self, max_line: usize) { + self.visible = true; + self.max_line = max_line; + } +} + +impl Component for GotoLinePopup { + /// + fn commands( + &self, + out: &mut Vec, + 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::goto_line(&self.key_config), + true, + true, + ) + .order(1), + ); + } + + visibility_blocking(self) + } + + fn is_visible(&self) -> bool { + self.visible + } + + /// + fn event(&mut self, event: &Event) -> Result { + if self.is_visible() { + if let Event::Key(key) = event { + if key_match(key, self.key_config.keys.exit_popup) { + self.visible = false; + self.input.clear(); + } else if let KeyCode::Char(c) = key.code { + if c.is_ascii_digit() || c == '-' { + self.input.push(c); + } + } else if key.code == KeyCode::Backspace { + self.input.pop(); + } else if key_match(key, self.key_config.keys.enter) { + self.visible = false; + if self.invalid_input { + self.queue.push(InternalEvent::ShowErrorMsg( + format!("Invalid input: only numbers between -{} and {} (included) are allowed (-1 denotes the last line, -2 denotes the second to last line, and so on)",self.max_line + 1, self.max_line)) + , + ); + } else if !self.input.is_empty() { + self.queue.push(InternalEvent::GotoLine( + self.line_number, + )); + } + self.input.clear(); + self.invalid_input = false; + } + } + match self.input.parse::() { + Ok(input) => { + let mut max_value_allowed_abs = self.max_line; + // negative indices are 1 based + if input < 0 { + max_value_allowed_abs += 1; + } + let input_abs = input.unsigned_abs(); + if input_abs > max_value_allowed_abs { + self.invalid_input = true; + } else { + self.invalid_input = false; + self.line_number = if input >= 0 { + input_abs + } else { + max_value_allowed_abs - input_abs + } + } + } + Err(_) => { + if !self.input.is_empty() { + self.invalid_input = true; + } + } + } + return Ok(EventState::Consumed); + } + Ok(EventState::NotConsumed) + } +} + +impl DrawableComponent for GotoLinePopup { + fn draw(&self, f: &mut Frame, area: Rect) -> Result<()> { + if self.is_visible() { + let style = if self.invalid_input { + Style::default().fg(Color::Red) + } else { + self.theme.text(true, false) + }; + let input = Paragraph::new(self.input.as_str()) + .style(style) + .block(Block::bordered().title("Go to")); + + let input_area = ui::centered_rect_absolute(15, 3, area); + f.render_widget(Clear, input_area); + f.render_widget(input, input_area); + } + + Ok(()) + } +} diff --git a/src/popups/mod.rs b/src/popups/mod.rs index cb3ae1af74..86d48ddef9 100644 --- a/src/popups/mod.rs +++ b/src/popups/mod.rs @@ -9,6 +9,7 @@ mod externaleditor; mod fetch; mod file_revlog; mod fuzzy_find; +mod goto_line; mod help; mod inspect_commit; mod log_search; @@ -39,6 +40,7 @@ pub use externaleditor::ExternalEditorPopup; pub use fetch::FetchPopup; pub use file_revlog::{FileRevOpen, FileRevlogPopup}; pub use fuzzy_find::FuzzyFindPopup; +pub use goto_line::GotoLinePopup; pub use help::HelpPopup; pub use inspect_commit::{InspectCommitOpen, InspectCommitPopup}; pub use log_search::LogSearchPopupPopup; diff --git a/src/queue.rs b/src/queue.rs index 635fbc9e71..339610032f 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -157,6 +157,10 @@ pub enum InternalEvent { RewordCommit(CommitId), /// CommitSearch(LogFilterSearchOptions), + /// + OpenGotoLinePopup(usize), + /// + GotoLine(usize), } /// single threaded simple queue for components to communicate with each other diff --git a/src/strings.rs b/src/strings.rs index 0b2d25efe7..6792a0ceaa 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -1449,6 +1449,18 @@ pub mod commands { CMD_GROUP_LOG, ) } + pub fn open_line_number_popup( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Go to [{}]", + key_config.get_hint(key_config.keys.goto_line), + ), + "go to a given line number in the blame view", + CMD_GROUP_GENERAL, + ) + } pub fn log_tag_commit( key_config: &SharedKeyConfig, ) -> CommandText { @@ -1870,4 +1882,15 @@ pub mod commands { CMD_GROUP_LOG, ) } + + pub fn goto_line(key_config: &SharedKeyConfig) -> CommandText { + CommandText::new( + format!( + "Go To [{}]", + key_config.get_hint(key_config.keys.enter), + ), + "Go to the given line", + CMD_GROUP_GENERAL, + ) + } }