diff --git a/CHANGELOG.md b/CHANGELOG.md index bf21609d..bf6ce322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * allow `copy` file path on revision files and status tree [[@yanganto]](https://github.com/yanganto) ([#1516](https://github.com/extrawurst/gitui/pull/1516)) * print message of where log will be written if `-l` is set ([#1472](https://github.com/extrawurst/gitui/pull/1472)) * show remote branches in log [[@cruessler](https://github.com/cruessler)] ([#1501](https://github.com/extrawurst/gitui/issues/1501)) +* support 'n'/'p' key to move to the next/prev hunk in diff component [[@hamflx](https://github.com/hamflx)] ([#1523](https://github.com/extrawurst/gitui/issues/1523)) ### Fixes * fixed side effect of crossterm 0.26 on windows that caused double input of all keys [[@pm100]](https://github/pm100) ([#1686](https://github.com/extrawurst/gitui/pull/1686)) diff --git a/src/components/diff.rs b/src/components/diff.rs index c36b8d05..bec75722 100644 --- a/src/components/diff.rs +++ b/src/components/diff.rs @@ -629,6 +629,50 @@ impl DiffComponent { Ok(()) } + fn calc_hunk_move_target( + &self, + direction: isize, + ) -> Option { + let diff = self.diff.as_ref()?; + if diff.hunks.is_empty() { + return None; + } + let max = diff.hunks.len() - 1; + let target_index = self.selected_hunk.map_or(0, |i| { + let target = if direction >= 0 { + i.saturating_add(direction.unsigned_abs()) + } else { + i.saturating_sub(direction.unsigned_abs()) + }; + std::cmp::min(max, target) + }); + Some(target_index) + } + + fn diff_hunk_move_up_down(&mut self, direction: isize) { + let Some(diff) = &self.diff else { return }; + let hunk_index = self.calc_hunk_move_target(direction); + // return if selected_hunk not change + if self.selected_hunk == hunk_index { + return; + } + if let Some(hunk_index) = hunk_index { + let line_index = diff + .hunks + .iter() + .take(hunk_index) + .fold(0, |sum, hunk| sum + hunk.lines.len()); + let hunk = &diff.hunks[hunk_index]; + self.selection = Selection::Single(line_index); + self.selected_hunk = Some(hunk_index); + self.vertical_scroll.move_area_to_visible( + self.current_size.get().1 as usize, + line_index, + line_index.saturating_add(hunk.lines.len()), + ); + } + } + const fn is_stage(&self) -> bool { self.current.is_stage } @@ -710,7 +754,16 @@ impl Component for DiffComponent { self.can_scroll(), self.focused(), )); - + out.push(CommandInfo::new( + strings::commands::diff_hunk_next(&self.key_config), + self.calc_hunk_move_target(1) != self.selected_hunk, + self.focused(), + )); + out.push(CommandInfo::new( + strings::commands::diff_hunk_prev(&self.key_config), + self.calc_hunk_move_target(-1) != self.selected_hunk, + self.focused(), + )); out.push( CommandInfo::new( strings::commands::diff_home_end(&self.key_config), @@ -769,7 +822,7 @@ impl Component for DiffComponent { CommandBlocking::PassingOn } - #[allow(clippy::cognitive_complexity)] + #[allow(clippy::cognitive_complexity, clippy::too_many_lines)] fn event(&mut self, ev: &Event) -> Result { if self.focused() { if let Event::Key(e) = ev { @@ -815,6 +868,18 @@ impl Component for DiffComponent { self.horizontal_scroll .move_right(HorizontalScrollType::Left); Ok(EventState::Consumed) + } else if key_match( + e, + self.key_config.keys.diff_hunk_next, + ) { + self.diff_hunk_move_up_down(1); + Ok(EventState::Consumed) + } else if key_match( + e, + self.key_config.keys.diff_hunk_prev, + ) { + self.diff_hunk_move_up_down(-1); + Ok(EventState::Consumed) } else if key_match( e, self.key_config.keys.stage_unstage_item, diff --git a/src/components/utils/scroll_vertical.rs b/src/components/utils/scroll_vertical.rs index 02b46ac9..70d68eef 100644 --- a/src/components/utils/scroll_vertical.rs +++ b/src/components/utils/scroll_vertical.rs @@ -51,6 +51,32 @@ impl VerticalScroll { true } + pub fn move_area_to_visible( + &self, + height: usize, + start: usize, + end: usize, + ) { + let top = self.top.get(); + let bottom = top + height; + let max_top = self.max_top.get(); + // the top of some content is hidden + if start < top { + self.top.set(start); + return; + } + // the bottom of some content is hidden and there is visible space available + if end > bottom && start > top { + let avail_space = start.saturating_sub(top); + let diff = std::cmp::min( + avail_space, + end.saturating_sub(bottom), + ); + let top = top.saturating_add(diff); + self.top.set(std::cmp::min(max_top, top)); + } + } + pub fn update( &self, selection: usize, @@ -136,4 +162,41 @@ mod tests { fn test_scroll_zero_height() { assert_eq!(calc_scroll_top(4, 0, 4, 3), 0); } + + #[test] + fn test_scroll_bottom_into_view() { + let visual_height = 10; + let line_count = 20; + let scroll = VerticalScroll::new(); + scroll.max_top.set(line_count - visual_height); + + // intersecting with the bottom of the visible area + scroll.move_area_to_visible(visual_height, 9, 11); + assert_eq!(scroll.get_top(), 1); + + // completely below the visible area + scroll.move_area_to_visible(visual_height, 15, 17); + assert_eq!(scroll.get_top(), 7); + + // scrolling to the bottom overflow + scroll.move_area_to_visible(visual_height, 30, 40); + assert_eq!(scroll.get_top(), 10); + } + + #[test] + fn test_scroll_top_into_view() { + let visual_height = 10; + let line_count = 20; + let scroll = VerticalScroll::new(); + scroll.max_top.set(line_count - visual_height); + scroll.top.set(4); + + // intersecting with the top of the visible area + scroll.move_area_to_visible(visual_height, 2, 8); + assert_eq!(scroll.get_top(), 2); + + // completely above the visible area + scroll.move_area_to_visible(visual_height, 0, 2); + assert_eq!(scroll.get_top(), 0); + } } diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index a3c79c93..134d992d 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -109,6 +109,8 @@ pub struct KeysList { pub pull: GituiKeyEvent, pub abort_merge: GituiKeyEvent, pub undo_commit: GituiKeyEvent, + pub diff_hunk_next: GituiKeyEvent, + pub diff_hunk_prev: GituiKeyEvent, pub stage_unstage_item: GituiKeyEvent, pub tag_annotate: GituiKeyEvent, pub view_submodules: GituiKeyEvent, @@ -193,6 +195,8 @@ impl Default for KeysList { open_file_tree: GituiKeyEvent::new(KeyCode::Char('F'), KeyModifiers::SHIFT), file_find: GituiKeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), branch_find: GituiKeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()), + diff_hunk_next: GituiKeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()), + diff_hunk_prev: GituiKeyEvent::new(KeyCode::Char('p'), KeyModifiers::empty()), stage_unstage_item: GituiKeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), tag_annotate: GituiKeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), view_submodules: GituiKeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT), diff --git a/src/strings.rs b/src/strings.rs index fc7ef107..3e1ff806 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -603,6 +603,30 @@ pub mod commands { CMD_GROUP_LOG, ) } + pub fn diff_hunk_next( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Next hunk [{}]", + key_config.get_hint(key_config.keys.diff_hunk_next), + ), + "move cursor to next hunk", + CMD_GROUP_DIFF, + ) + } + pub fn diff_hunk_prev( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Prev hunk [{}]", + key_config.get_hint(key_config.keys.diff_hunk_prev), + ), + "move cursor to prev hunk", + CMD_GROUP_DIFF, + ) + } pub fn diff_home_end( key_config: &SharedKeyConfig, ) -> CommandText {