diff --git a/CHANGELOG.md b/CHANGELOG.md index 012b60e5ff..477f73bb47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,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 syntax highlighting file detection [[@acuteenvy](https://github.com/acuteenvy)] ([#2524](https://github.com/extrawurst/gitui/pull/2524)) @@ -34,13 +35,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * use default shell instead of bash on Unix-like OS [[@yerke](https://github.com/yerke)] ([#2343](https://github.com/gitui-org/gitui/pull/2343)) ### Added -* add popups for viewing, adding, updating and removing remotes [[@robin-thoene](https://github.com/robin-thoene)] ([#2172](https://github.com/gitui-org/gitui/issues/2172)) -* support for `Copy Path` action in WSL [[@johnDeSilencio](https://github.com/johnDeSilencio)] ([#2413](https://github.com/gitui-org/gitui/pull/2413)) -* help popup scrollbar [[@wugeer](https://github.com/wugeer)] ([#2388](https://github.com/gitui-org/gitui/pull/2388)) -### Fixes -* respect env vars like `GIT_CONFIG_GLOBAL` ([#2298](https://github.com/gitui-org/gitui/issues/2298)) -* Set `CREATE_NO_WINDOW` flag when executing Git hooks on Windows ([#2371](https://github.com/gitui-org/gitui/pull/2371)) +* support for "Copy Path" action in WSL [[@johnDeSilencio](https://github.com/johnDeSilencio)] ([#2413](https://github.com/extrawurst/gitui/pull/2413)) +* help popup scrollbar [[@wugeer](https://github.com/wugeer)](https://github.com/extrawurst/gitui/pull/2388)) +* add popups for viewing, adding, updating and removing remotes [[@robin-thoene](https://github.com/robin-thoene)] ([#2172](https://github.com/extrawurst/gitui/issues/2172)) ## [0.26.3] - 2024-06-02 diff --git a/asyncgit/src/blame.rs b/asyncgit/src/blame.rs index dcbb93aff0..0c99478512 100644 --- a/asyncgit/src/blame.rs +++ b/asyncgit/src/blame.rs @@ -14,7 +14,7 @@ use std::{ }; /// -#[derive(Hash, Clone, PartialEq, Eq)] +#[derive(Hash, Clone, PartialEq, Eq, Debug)] pub struct BlameParams { /// path to the file to blame pub file_path: String, @@ -22,15 +22,17 @@ pub struct BlameParams { pub commit_id: Option<CommitId>, } +#[derive(Debug)] struct Request<R, A>(R, Option<A>); -#[derive(Default, Clone)] +#[derive(Debug, Default, Clone)] struct LastResult<P, R> { params: P, result: R, } /// +#[derive(Debug, Clone)] pub struct AsyncBlame { current: Arc<Mutex<Request<u64, FileBlame>>>, last: Arc<Mutex<Option<LastResult<BlameParams, FileBlame>>>>, diff --git a/src/app.rs b/src/app.rs index 45037f048f..3a2dfd5dfb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,16 +10,16 @@ use crate::{ options::{Options, SharedOptions}, popup_stack::PopupStack, popups::{ - AppOption, BlameFilePopup, BranchListPopup, CommitPopup, - CompareCommitsPopup, ConfirmPopup, CreateBranchPopup, - CreateRemotePopup, ExternalEditorPopup, FetchPopup, - FileRevlogPopup, FuzzyFindPopup, HelpPopup, - InspectCommitPopup, LogSearchPopupPopup, MsgPopup, - OptionsPopup, PullPopup, PushPopup, PushTagsPopup, - RemoteListPopup, RenameBranchPopup, RenameRemotePopup, - ResetPopup, RevisionFilesPopup, StashMsgPopup, - SubmodulesListPopup, TagCommitPopup, TagListPopup, - UpdateRemoteUrlPopup, + AppOption, BlameFileOpen, BlameFilePopup, BlameRequest, + BranchListPopup, CommitPopup, CompareCommitsPopup, + ConfirmPopup, CreateBranchPopup, CreateRemotePopup, + ExternalEditorPopup, FetchPopup, FileRevlogPopup, + FuzzyFindPopup, GotoLinePopup, HelpPopup, InspectCommitPopup, + LogSearchPopupPopup, MsgPopup, OptionsPopup, PullPopup, + PushPopup, PushTagsPopup, RemoteListPopup, RenameBranchPopup, + RenameRemotePopup, ResetPopup, RevisionFilesPopup, + StashMsgPopup, SubmodulesListPopup, TagCommitPopup, + TagListPopup, UpdateRemoteUrlPopup, }, queue::{ Action, AppTabs, InternalEvent, NeedsUpdate, Queue, @@ -112,6 +112,7 @@ pub struct App { popup_stack: PopupStack, options: SharedOptions, repo_path_text: String, + goto_line_popup: GotoLinePopup, // "Flags" requires_redraw: Cell<bool>, @@ -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 ] ); @@ -691,6 +695,9 @@ impl App { StackablePopupOpen::CompareCommits(param) => { self.compare_commits_popup.open(param)?; } + StackablePopupOpen::GotoLine(param) => { + self.goto_line_popup.open(param); + } } Ok(()) @@ -905,6 +912,26 @@ impl App { InternalEvent::CommitSearch(options) => { self.revlog.search(options); } + InternalEvent::GotoLine(line) => { + if let Some(popup) = self.popup_stack.pop() { + if let StackablePopupOpen::BlameFile(params) = + popup + { + self.popup_stack.push( + StackablePopupOpen::BlameFile( + BlameFileOpen { + selection: Some(line), + blame: BlameRequest::KeepExisting, + ..params + }, + ), + ); + } + flags.insert( + NeedsUpdate::ALL | NeedsUpdate::COMMANDS, + ); + } + } }; Ok(flags) diff --git a/src/components/revision_files.rs b/src/components/revision_files.rs index 8a56071684..231c02094c 100644 --- a/src/components/revision_files.rs +++ b/src/components/revision_files.rs @@ -6,7 +6,7 @@ use super::{ use crate::{ app::Environment, keys::{key_match, SharedKeyConfig}, - popups::{BlameFileOpen, FileRevOpen}, + popups::{BlameFileOpen, BlameRequest, FileRevOpen}, queue::{InternalEvent, Queue, StackablePopupOpen}, strings::{self, order, symbol}, try_or_popup, @@ -193,6 +193,7 @@ impl RevisionFilesComponent { file_path: path, commit_id: self.revision.as_ref().map(|c| c.id), selection: None, + blame: BlameRequest::StartNew, }), )); diff --git a/src/components/status_tree.rs b/src/components/status_tree.rs index 4fb762c1af..1d836bbe00 100644 --- a/src/components/status_tree.rs +++ b/src/components/status_tree.rs @@ -9,7 +9,7 @@ use crate::{ app::Environment, components::{CommandInfo, Component, EventState}, keys::{key_match, SharedKeyConfig}, - popups::{BlameFileOpen, FileRevOpen}, + popups::{BlameFileOpen, BlameRequest, FileRevOpen}, queue::{InternalEvent, NeedsUpdate, Queue, StackablePopupOpen}, strings::{self, order}, ui::{self, style::SharedTheme}, @@ -456,6 +456,7 @@ impl Component for StatusTreeComponent { file_path: status_item.path, commit_id: self.revision, selection: None, + blame: BlameRequest::StartNew, }, ), )); 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 273ca5ace3..c70b4720a7 100644 --- a/src/popups/blame_file.rs +++ b/src/popups/blame_file.rs @@ -30,12 +30,15 @@ use ratatui::{ }; use std::path::Path; +use super::{goto_line::GotoLineContext, GotoLineOpen}; + static NO_COMMIT_ID: &str = "0000000"; static NO_AUTHOR: &str = "<no author>"; static MIN_AUTHOR_WIDTH: usize = 3; static MAX_AUTHOR_WIDTH: usize = 20; -struct SyntaxFileBlame { +#[derive(Debug, Clone)] +pub struct SyntaxFileBlame { pub file_blame: FileBlame, pub styled_text: Option<SyntaxText>, } @@ -54,7 +57,8 @@ impl SyntaxFileBlame { } } -enum BlameProcess { +#[derive(Clone, Debug)] +pub enum BlameProcess { GettingBlame(AsyncBlame), SyntaxHighlighting { unstyled_file_blame: SyntaxFileBlame, @@ -76,11 +80,18 @@ impl BlameProcess { } } +#[derive(Clone, Debug)] +pub enum BlameRequest { + StartNew, + KeepExisting, +} + #[derive(Clone, Debug)] pub struct BlameFileOpen { pub file_path: String, pub commit_id: Option<CommitId>, pub selection: Option<usize>, + pub blame: BlameRequest, } pub struct BlameFilePopup { @@ -234,6 +245,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 +328,31 @@ 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 maybe_blame_result.is_some() { + let max_line = maybe_blame_result + .expect("This can not be None") + .lines() + .len() - 1; + self.hide_stacked(true); + self.visible = true; + self.queue.push(InternalEvent::OpenPopup( + StackablePopupOpen::GotoLine( + GotoLineOpen { + context: GotoLineContext { + max_line, + }, + }, + ), + )); + } } return Ok(EventState::Consumed); @@ -356,6 +402,7 @@ impl BlameFilePopup { file_path: request.file_path, commit_id: request.commit_id, selection: self.get_selection(), + blame: BlameRequest::KeepExisting, }), )); } @@ -371,11 +418,13 @@ impl BlameFilePopup { file_path: open.file_path, commit_id: open.commit_id, }); - self.blame = - Some(BlameProcess::GettingBlame(AsyncBlame::new( - self.repo.borrow().clone(), - &self.git_sender, - ))); + if matches!(open.blame, BlameRequest::StartNew) { + self.blame = + Some(BlameProcess::GettingBlame(AsyncBlame::new( + self.repo.borrow().clone(), + &self.git_sender, + ))); + } self.table_state.get_mut().select(Some(0)); self.visible = true; self.update()?; @@ -438,7 +487,6 @@ impl BlameFilePopup { ), }, ); - self.set_open_selection(); self.highlight_blame_lines(); return Ok(()); @@ -449,6 +497,7 @@ impl BlameFilePopup { } } } + self.set_open_selection(); Ok(()) } @@ -722,7 +771,9 @@ impl BlameFilePopup { self.open_request.as_ref().and_then(|req| req.selection) { let mut table_state = self.table_state.take(); - table_state.select(Some(selection)); + let max_line_number = self.get_max_line_number(); + table_state + .select(Some(selection.clamp(0, max_line_number))); self.table_state.set(table_state); } } diff --git a/src/popups/file_revlog.rs b/src/popups/file_revlog.rs index c946323b5d..3ad57074fc 100644 --- a/src/popups/file_revlog.rs +++ b/src/popups/file_revlog.rs @@ -28,7 +28,7 @@ use ratatui::{ Frame, }; -use super::{BlameFileOpen, InspectCommitOpen}; +use super::{BlameFileOpen, BlameRequest, InspectCommitOpen}; const SLICE_SIZE: usize = 1200; @@ -533,6 +533,7 @@ impl Component for FileRevlogPopup { file_path: open_request.file_path, commit_id: self.selected_commit(), selection: None, + blame: BlameRequest::StartNew, }, ), )); diff --git a/src/popups/goto_line.rs b/src/popups/goto_line.rs new file mode 100644 index 0000000000..f900b2aa14 --- /dev/null +++ b/src/popups/goto_line.rs @@ -0,0 +1,180 @@ +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}; + +#[derive(Debug)] +pub struct GotoLineContext { + pub max_line: usize, +} + +#[derive(Debug)] +pub struct GotoLineOpen { + pub context: GotoLineContext, +} + +pub struct GotoLinePopup { + visible: bool, + input: String, + line_number: usize, + key_config: SharedKeyConfig, + queue: Queue, + theme: SharedTheme, + invalid_input: bool, + context: GotoLineContext, +} + +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, + context: GotoLineContext { max_line: 0 }, + line_number: 0, + } + } + + pub fn open(&mut self, open: GotoLineOpen) { + self.visible = true; + self.context = open.context; + } +} + +impl Component for GotoLinePopup { + /// + fn commands( + &self, + out: &mut Vec<CommandInfo>, + 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<EventState> { + 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(); + self.queue.push(InternalEvent::PopupStackPop); + } 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.context.max_line + 1, self.context.max_line)) + , + ); + } else if !self.input.is_empty() { + self.queue.push(InternalEvent::GotoLine( + self.line_number, + )); + } + self.queue.push(InternalEvent::PopupStackPop); + self.input.clear(); + self.invalid_input = false; + } + } + match self.input.parse::<isize>() { + Ok(input) => { + let mut max_value_allowed_abs = + self.context.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 Line")); + + 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..fe746d9983 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; @@ -28,7 +29,7 @@ mod tag_commit; mod taglist; mod update_remote_url; -pub use blame_file::{BlameFileOpen, BlameFilePopup}; +pub use blame_file::{BlameFileOpen, BlameFilePopup, BlameRequest}; pub use branchlist::BranchListPopup; pub use commit::CommitPopup; pub use compare_commits::CompareCommitsPopup; @@ -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::{GotoLineOpen, 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 44268a851d..7e2054dc15 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -2,7 +2,7 @@ use crate::{ components::FuzzyFinderTarget, popups::{ AppOption, BlameFileOpen, FileRevOpen, FileTreeOpen, - InspectCommitOpen, + GotoLineOpen, InspectCommitOpen, }, tabs::StashingOptions, }; @@ -71,6 +71,8 @@ pub enum StackablePopupOpen { InspectCommit(InspectCommitOpen), /// CompareCommits(InspectCommitOpen), + /// + GotoLine(GotoLineOpen), } pub enum AppTabs { @@ -157,6 +159,8 @@ pub enum InternalEvent { RewordCommit(CommitId), /// CommitSearch(LogFilterSearchOptions), + /// + GotoLine(usize), } /// single threaded simple queue for components to communicate with each other diff --git a/src/strings.rs b/src/strings.rs index c4cff10f70..62a2c6e564 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 Line [{}]", + 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 Line [{}]", + key_config.get_hint(key_config.keys.enter), + ), + "Go to the given line", + CMD_GROUP_GENERAL, + ) + } } diff --git a/src/ui/syntax_text.rs b/src/ui/syntax_text.rs index 8d758f20cc..3dac2a1cdf 100644 --- a/src/ui/syntax_text.rs +++ b/src/ui/syntax_text.rs @@ -23,10 +23,12 @@ use crate::{AsyncAppNotification, SyntaxHighlightProgress}; pub const DEFAULT_SYNTAX_THEME: &str = "base16-eighties.dark"; +#[derive(Debug, Clone)] struct SyntaxLine { items: Vec<(Style, usize, Range<usize>)>, } +#[derive(Debug, Clone)] pub struct SyntaxText { text: String, lines: Vec<SyntaxLine>, @@ -222,12 +224,13 @@ fn syntact_style_to_tui(style: &Style) -> ratatui::style::Style { res } +#[derive(Debug)] enum JobState { Request((String, String)), Response(SyntaxText), } -#[derive(Clone, Default)] +#[derive(Clone, Default, Debug)] pub struct AsyncSyntaxJob { state: Arc<Mutex<Option<JobState>>>, syntax: String,