diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdad9ba8f..bdd1f42377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed * use [tombi](https://github.com/tombi-toml/tombi) for all toml file formatting * open the external editor from the status diff view [[@WaterWhisperer](https://github.com/WaterWhisperer)] ([#2805](https://github.com/gitui-org/gitui/issues/2805)) +* change diff mode toggle shortcut from `Alt+p` to `m` ### Fixes * crash when opening submodule ([#2895](https://github.com/gitui-org/gitui/issues/2895)) * when staging the last file in a directory, the first item after the directory is no longer skipped [[@Tillerino](https://github.com/Tillerino)] ([#2748](https://github.com/gitui-org/gitui/issues/2748)) +* fixed duplicated "Toggle Diff Mode" in help message ## [0.28.1] - 2026-03-21 diff --git a/src/components/diff.rs b/src/components/diff.rs index 04779caada..c98c1ef4d0 100644 --- a/src/components/diff.rs +++ b/src/components/diff.rs @@ -23,14 +23,27 @@ use asyncgit::{ use bytesize::ByteSize; use crossterm::event::Event; use ratatui::{ - layout::Rect, + layout::{ + Constraint, Direction as RatatuiDirection, Layout, Rect, + }, symbols, text::{Line, Span}, widgets::{Block, Borders, Paragraph}, Frame, }; +use serde::{Deserialize, Serialize}; use std::{borrow::Cow, cell::Cell, cmp, path::Path}; +/// Diff display mode +#[derive( + Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, +)] +pub enum DiffMode { + #[default] + Unified, + SideBySide, +} + #[derive(Default)] struct Current { path: String, @@ -101,6 +114,24 @@ impl Selection { } } +/// A single line in side-by-side diff view +struct SideBySideLine { + left_content: String, + left_line_num: Option, + right_content: String, + right_line_num: Option, + left_type: DiffLineType, + right_type: DiffLineType, + /// Global line index for selection tracking + global_line_idx: usize, + /// Index of the hunk this line belongs to + hunk_idx: usize, + /// Whether this is the first line of a hunk + is_hunk_start: bool, + /// Whether this is the last line of a hunk + is_hunk_end: bool, +} + /// pub struct DiffComponent { repo: RepoPathRef, @@ -119,6 +150,7 @@ pub struct DiffComponent { key_config: SharedKeyConfig, is_immutable: bool, options: SharedOptions, + diff_mode: DiffMode, } impl DiffComponent { @@ -141,6 +173,7 @@ impl DiffComponent { is_immutable, repo: env.repo.clone(), options: env.options.clone(), + diff_mode: env.options.borrow().diff_mode(), } } /// @@ -223,11 +256,19 @@ impl DiffComponent { fn move_selection(&mut self, move_type: ScrollType) { if let Some(diff) = &self.diff { - let max = diff.lines.saturating_sub(1); + // In side-by-side mode, display lines differ from diff.lines + // because Delete+Add pairs are shown as one line + let max = if self.diff_mode == DiffMode::SideBySide { + self.side_by_side_lines_count().saturating_sub(1) + } else { + diff.lines.saturating_sub(1) + }; let new_start = match move_type { ScrollType::Down => { - self.selection.get_bottom().saturating_add(1) + let next = + self.selection.get_bottom().saturating_add(1); + cmp::min(next, max) } ScrollType::Up => { self.selection.get_top().saturating_sub(1) @@ -235,10 +276,14 @@ impl DiffComponent { ScrollType::Home => 0, ScrollType::End => max, ScrollType::PageDown => { - self.selection.get_bottom().saturating_add( - self.current_size.get().1.saturating_sub(1) - as usize, - ) + let next = + self.selection.get_bottom().saturating_add( + self.current_size + .get() + .1 + .saturating_sub(1) as usize, + ); + cmp::min(next, max) } ScrollType::PageUp => { self.selection.get_top().saturating_sub( @@ -254,11 +299,20 @@ impl DiffComponent { fn update_selection(&mut self, new_start: usize) { if let Some(diff) = &self.diff { - let max = diff.lines.saturating_sub(1); + // In side-by-side mode, display lines differ from diff.lines + let max = if self.diff_mode == DiffMode::SideBySide { + self.side_by_side_lines_count().saturating_sub(1) + } else { + diff.lines.saturating_sub(1) + }; let new_start = cmp::min(max, new_start); self.selection = Selection::Single(new_start); self.selected_hunk = - Self::find_selected_hunk(diff, new_start); + Self::find_selected_hunk_for_display_line( + diff, + new_start, + self.diff_mode, + ); } } @@ -266,9 +320,52 @@ impl DiffComponent { self.diff.as_ref().map_or(0, |diff| diff.lines) } + /// Get the actual display line count for side-by-side mode + /// In side-by-side mode, Delete+Add pairs are shown as one line + fn side_by_side_lines_count(&self) -> usize { + let Some(diff) = &self.diff else { + return 0; + }; + + if diff.hunks.is_empty() { + return 0; + } + + let mut count = 0_usize; + for hunk in &diff.hunks { + let mut i = 0; + while i < hunk.lines.len() { + let line = &hunk.lines[i]; + if line.line_type == DiffLineType::Delete { + // Check if next line is Add (they will be paired) + if let Some(next) = hunk.lines.get(i + 1) { + if next.line_type == DiffLineType::Add { + i += 1; // Skip the Add line in counting + } + } + } + count += 1; + i += 1; + } + } + + count + } + fn max_scroll_right(&self) -> usize { - self.longest_line - .saturating_sub(self.current_size.get().0.into()) + let available_width: usize = if self.diff_mode + == DiffMode::SideBySide + { + // In side-by-side mode, each panel's content width: + // chunks[0].width ≈ r.width / 2 + // content width = chunks[0].width - 7 (border + marker + line_num + space) + // current_width = r.width - 2 + // So: r.width / 2 - 7 ≈ current_width / 2 - 6 + (self.current_size.get().0 / 2).saturating_sub(6).into() + } else { + self.current_size.get().0.into() + }; + self.longest_line.saturating_sub(available_width) } fn modify_selection(&mut self, direction: Direction) { @@ -328,6 +425,50 @@ impl DiffComponent { None } + /// Find the hunk index for a display line (accounting for side-by-side pairing) + fn find_selected_hunk_for_display_line( + diff: &FileDiff, + display_line_selected: usize, + diff_mode: DiffMode, + ) -> Option { + if diff_mode == DiffMode::Unified { + return Self::find_selected_hunk( + diff, + display_line_selected, + ); + } + + // For side-by-side mode, count display lines (where Delete+Add pairs count as 1) + let mut display_cursor = 0_usize; + for (i, hunk) in diff.hunks.iter().enumerate() { + let mut j = 0; + let hunk_start = display_cursor; + while j < hunk.lines.len() { + let line = &hunk.lines[j]; + if display_cursor == display_line_selected { + return Some(i); + } + if line.line_type == DiffLineType::Delete { + if let Some(next) = hunk.lines.get(j + 1) { + if next.line_type == DiffLineType::Add { + j += 1; + } + } + } + display_cursor += 1; + j += 1; + } + // Check if this is the last line of the hunk + if display_line_selected >= hunk_start + && display_line_selected < display_cursor + { + return Some(i); + } + } + + None + } + fn get_text(&self, width: u16, height: u16) -> Vec> { if let Some(diff) = &self.diff { return if diff.hunks.is_empty() { @@ -677,6 +818,485 @@ impl DiffComponent { } } + /// Toggle between unified and side-by-side diff mode + pub fn toggle_diff_mode(&mut self) { + self.diff_mode = match self.diff_mode { + DiffMode::Unified => DiffMode::SideBySide, + DiffMode::SideBySide => DiffMode::Unified, + }; + self.options.borrow_mut().set_diff_mode(self.diff_mode); + } + + fn get_side_by_side_lines( + &self, + height: u16, + ) -> Vec { + let Some(diff) = &self.diff else { + return Vec::new(); + }; + + if diff.hunks.is_empty() { + return Vec::new(); + } + + let min = self.vertical_scroll.get_top(); + let max = min + height as usize; + + let mut result = Vec::new(); + // Use display_cursor to track display line index (where Delete+Add pairs count as 1) + let mut display_cursor = 0_usize; + + for (hunk_idx, hunk) in diff.hunks.iter().enumerate() { + // Calculate display line range for this hunk + let hunk_display_start = display_cursor; + let mut hunk_display_len = 0_usize; + { + let mut j = 0; + while j < hunk.lines.len() { + let line = &hunk.lines[j]; + if line.line_type == DiffLineType::Delete { + if let Some(next) = hunk.lines.get(j + 1) { + if next.line_type == DiffLineType::Add { + j += 1; + } + } + } + hunk_display_len += 1; + j += 1; + } + } + let hunk_display_end = + hunk_display_start + hunk_display_len; + + if Self::hunk_visible( + hunk_display_start, + hunk_display_end, + min, + max, + ) { + let mut i = 0; + while i < hunk.lines.len() { + let line = &hunk.lines[i]; + let global_display_idx = display_cursor; + let is_hunk_start = i == 0; + // Calculate if this is the last display line of the hunk + let is_hunk_end = { + let mut remaining = hunk.lines.len() - i; + let next = hunk.lines.get(i + 1); + if line.line_type == DiffLineType::Delete + && next.is_some_and(|n| { + n.line_type == DiffLineType::Add + }) { + remaining -= 1; + } + remaining == 1 + }; + + if global_display_idx >= min + && global_display_idx <= max + { + match line.line_type { + DiffLineType::Delete => { + // Look ahead for a matching add line + let next_line = hunk.lines.get(i + 1); + let ( + right_content, + right_num, + right_type, + ) = if let Some(next) = next_line { + if next.line_type + == DiffLineType::Add + { + i += 1; + ( + tabs_to_spaces( + next.content + .as_ref() + .to_string(), + ), + next.position.new_lineno, + DiffLineType::Add, + ) + } else { + ( + String::new(), + None, + DiffLineType::None, + ) + } + } else { + ( + String::new(), + None, + DiffLineType::None, + ) + }; + + result.push(SideBySideLine { + left_content: tabs_to_spaces( + line.content + .as_ref() + .to_string(), + ), + left_line_num: line + .position + .old_lineno, + right_content, + right_line_num: right_num, + left_type: DiffLineType::Delete, + right_type, + global_line_idx: + global_display_idx, + hunk_idx, + is_hunk_start, + is_hunk_end, + }); + } + DiffLineType::Add => { + // Add line not paired with a delete + result.push(SideBySideLine { + left_content: String::new(), + left_line_num: None, + right_content: tabs_to_spaces( + line.content + .as_ref() + .to_string(), + ), + right_line_num: line + .position + .new_lineno, + left_type: DiffLineType::None, + right_type: DiffLineType::Add, + global_line_idx: + global_display_idx, + hunk_idx, + is_hunk_start, + is_hunk_end, + }); + } + DiffLineType::Header => { + let header_content = tabs_to_spaces( + line.content.as_ref().to_string(), + ); + result.push(SideBySideLine { + left_content: header_content, + left_line_num: None, + right_content: String::new(), + right_line_num: None, + left_type: DiffLineType::Header, + right_type: DiffLineType::Header, + global_line_idx: + global_display_idx, + hunk_idx, + is_hunk_start, + is_hunk_end, + }); + } + DiffLineType::None => { + // Context line - appears in both columns + result.push(SideBySideLine { + left_content: tabs_to_spaces( + line.content + .as_ref() + .to_string(), + ), + left_line_num: line + .position + .old_lineno, + right_content: tabs_to_spaces( + line.content + .as_ref() + .to_string(), + ), + right_line_num: line + .position + .new_lineno, + left_type: DiffLineType::None, + right_type: DiffLineType::None, + global_line_idx: + global_display_idx, + hunk_idx, + is_hunk_start, + is_hunk_end, + }); + } + } + } + + // Increment display cursor for each display line + display_cursor += 1; + i += 1; + } + } else { + // Skip this hunk's display lines + display_cursor += hunk_display_len; + } + } + + result + } + + fn draw_side_by_side( + &self, + f: &mut Frame, + r: Rect, + title: &str, + height: u16, + ) -> Result<()> { + // Split area into left and right columns + let chunks = Layout::default() + .direction(RatatuiDirection::Horizontal) + .constraints( + [ + Constraint::Percentage(50), + Constraint::Percentage(50), + ] + .as_ref(), + ) + .split(r); + + // Calculate available width for content (subtract borders, marker, line number, space) + // Each panel has: 1 border + 1 marker + 4 line num + 1 space = 7 chars overhead + let panel_width = chunks[0].width.saturating_sub(7) as usize; + + let lines = self.get_side_by_side_lines(height); + let scrolled_right = self.horizontal_scroll.get_right(); + let selected_hunk = self.selected_hunk; + + // Get current selection index + let current_selection = self.selection.get_end(); + + // Build left column text with selection highlighting + let left_txt: Vec = lines + .iter() + .map(|line| { + let selected = self.focused() + && line.global_line_idx == current_selection; + let hunk_selected = self.focused() + && selected_hunk + .is_some_and(|h| h == line.hunk_idx); + let left_content = + trim_offset(&line.left_content, scrolled_right); + let line_num_str = line + .left_line_num + .map_or(String::from(" "), |n| { + format!("{n:4}") + }); + + // Get hunk marker style + let marker_style = + self.theme.diff_hunk_marker(hunk_selected); + let marker = if line.is_hunk_end { + symbols::line::BOTTOM_LEFT + } else if line.is_hunk_start { + symbols::line::TOP_LEFT + } else { + symbols::line::VERTICAL + }; + + // Pad content to fill width when selected + let content = if selected { + format!("{:w$}\n", left_content, w = panel_width) + } else { + format!("{left_content}\n") + }; + + // For lines where left side is empty (e.g., Add lines without Delete pair), + // still apply selection highlight to maintain visual consistency. + // Show line_break symbol (¶) for empty Add/Delete lines, same as unified mode. + if line.left_content.is_empty() { + // Show line_break symbol for empty Add/Delete lines + let display_content = + if line.left_type != DiffLineType::None { + self.theme.line_break() + } else { + String::new() + }; + let content = if selected { + format!( + "{:w$}\n", + display_content, + w = panel_width + ) + } else { + format!("{display_content}\n") + }; + Line::from(vec![ + Span::styled(Cow::from(marker), marker_style), + Span::styled( + Cow::from(line_num_str), + self.theme.text(false, false), + ), + // Gap - never highlighted + Span::styled( + Cow::from(" "), + self.theme.text(false, false), + ), + Span::styled( + Cow::from(content), + self.theme + .diff_line(line.left_type, selected), + ), + ]) + } else { + Line::from(vec![ + Span::styled(Cow::from(marker), marker_style), + Span::styled( + Cow::from(line_num_str), + self.theme.text(false, false), + ), + // Gap between line number and content - never highlighted + Span::styled( + Cow::from(" "), + self.theme.text(false, false), + ), + Span::styled( + Cow::from(content), + self.theme + .diff_line(line.left_type, selected), + ), + ]) + } + }) + .collect(); + + // Build right column text with selection highlighting + let right_txt: Vec = lines + .iter() + .map(|line| { + let selected = self.focused() + && line.global_line_idx == current_selection; + let hunk_selected = self.focused() + && selected_hunk + .is_some_and(|h| h == line.hunk_idx); + let right_content = + trim_offset(&line.right_content, scrolled_right); + let line_num_str = line + .right_line_num + .map_or(String::from(" "), |n| { + format!("{n:4}") + }); + + // Get hunk marker style + let marker_style = + self.theme.diff_hunk_marker(hunk_selected); + let marker = if line.is_hunk_end { + symbols::line::BOTTOM_LEFT + } else if line.is_hunk_start { + symbols::line::TOP_LEFT + } else { + symbols::line::VERTICAL + }; + + // Pad content to fill width when selected + let content = if selected { + format!("{:w$}\n", right_content, w = panel_width) + } else { + format!("{right_content}\n") + }; + + // For lines where right side is empty (Header or paired Delete), + // still apply selection highlight to maintain visual consistency. + // Show line_break symbol (¶) for empty Add/Delete lines, same as unified mode. + if line.right_type == DiffLineType::Header + || line.right_content.is_empty() + { + // Show line_break symbol for empty Add/Delete lines (but not Header) + let display_content = if line.right_type + != DiffLineType::None + && line.right_type != DiffLineType::Header + { + self.theme.line_break() + } else { + String::new() + }; + let filler = if selected { + format!( + "{:w$}\n", + display_content, + w = panel_width + ) + } else { + format!("{display_content}\n") + }; + Line::from(vec![ + Span::styled(Cow::from(marker), marker_style), + Span::styled( + Cow::from(line_num_str), + self.theme.text(false, false), + ), + // Gap - never highlighted + Span::styled( + Cow::from(" "), + self.theme.text(false, false), + ), + Span::styled( + Cow::from(filler), + self.theme + .diff_line(line.right_type, selected), + ), + ]) + } else { + Line::from(vec![ + Span::styled(Cow::from(marker), marker_style), + Span::styled( + Cow::from(line_num_str), + self.theme.text(false, false), + ), + // Gap between line number and content - never highlighted + Span::styled( + Cow::from(" "), + self.theme.text(false, false), + ), + Span::styled( + Cow::from(content), + self.theme + .diff_line(line.right_type, selected), + ), + ]) + } + }) + .collect(); + + // Draw left column + f.render_widget( + Paragraph::new(left_txt).block( + Block::default() + .title(Span::styled( + format!("{title} [Old]"), + self.theme.title(self.focused()), + )) + .borders(Borders::ALL) + .border_style(self.theme.block(self.focused())), + ), + chunks[0], + ); + + // Draw right column + f.render_widget( + Paragraph::new(right_txt).block( + Block::default() + .title(Span::styled( + "[New]", + self.theme.title(self.focused()), + )) + .borders(Borders::ALL) + .border_style(self.theme.block(self.focused())), + ), + chunks[1], + ); + + if self.focused() { + self.vertical_scroll.draw(f, r, &self.theme); + + if self.max_scroll_right() > 0 { + self.horizontal_scroll.draw(f, r, &self.theme); + } + } + + Ok(()) + } + const fn is_stage(&self) -> bool { self.current.is_stage } @@ -692,15 +1312,32 @@ impl DrawableComponent for DiffComponent { let current_width = self.current_size.get().0; let current_height = self.current_size.get().1; + // Use display line count for side-by-side mode + let lines_count = if self.diff_mode == DiffMode::SideBySide { + self.side_by_side_lines_count() + } else { + self.lines_count() + }; + self.vertical_scroll.update( self.selection.get_end(), - self.lines_count(), + lines_count, usize::from(current_height), ); + // In side-by-side mode, each panel content width is smaller + // chunks[0].width ≈ r.width / 2, content = chunks[0].width - 7 + // ≈ current_width / 2 - 6 + let panel_content_width: usize = + if self.diff_mode == DiffMode::SideBySide { + (current_width / 2).saturating_sub(6).into() + } else { + current_width.into() + }; + self.horizontal_scroll.update_no_selection( self.longest_line, - current_width.into(), + panel_content_width, ); let title = format!( @@ -709,33 +1346,41 @@ impl DrawableComponent for DiffComponent { self.current.path ); - let txt = if self.pending { - vec![Line::from(vec![Span::styled( - Cow::from(strings::loading_text(&self.key_config)), - self.theme.text(false, false), - )])] + if self.diff_mode == DiffMode::SideBySide && !self.pending { + self.draw_side_by_side(f, r, &title, current_height)?; } else { - self.get_text(r.width, current_height) - }; + let txt = if self.pending { + vec![Line::from(vec![Span::styled( + Cow::from(strings::loading_text( + &self.key_config, + )), + self.theme.text(false, false), + )])] + } else { + self.get_text(r.width, current_height) + }; - f.render_widget( - Paragraph::new(txt).block( - Block::default() - .title(Span::styled( - title.as_str(), - self.theme.title(self.focused()), - )) - .borders(Borders::ALL) - .border_style(self.theme.block(self.focused())), - ), - r, - ); + f.render_widget( + Paragraph::new(txt).block( + Block::default() + .title(Span::styled( + title.as_str(), + self.theme.title(self.focused()), + )) + .borders(Borders::ALL) + .border_style( + self.theme.block(self.focused()), + ), + ), + r, + ); - if self.focused() { - self.vertical_scroll.draw(f, r, &self.theme); + if self.focused() { + self.vertical_scroll.draw(f, r, &self.theme); - if self.max_scroll_right() > 0 { - self.horizontal_scroll.draw(f, r, &self.theme); + if self.max_scroll_right() > 0 { + self.horizontal_scroll.draw(f, r, &self.theme); + } } } @@ -943,6 +1588,12 @@ impl Component for DiffComponent { } else if key_match(e, self.key_config.keys.copy) { self.copy_selection(); Ok(EventState::Consumed) + } else if key_match( + e, + self.key_config.keys.diff_mode_toggle, + ) { + self.toggle_diff_mode(); + Ok(EventState::Consumed) } else { Ok(EventState::NotConsumed) }; @@ -1059,4 +1710,35 @@ mod tests { if path == "src/main.rs" )); } + + #[test] + fn test_commands_no_longer_contains_toggle_diff() { + let env = Environment::test_env(); + let diff = DiffComponent::new(&env, false); + let mut cmds = Vec::new(); + diff.commands(&mut cmds, true); + + let contains_toggle = cmds.iter().any(|c| { + c.text.name + == strings::commands::diff_toggle_mode(&env.key_config) + .name + }); + assert!(!contains_toggle); + } + + #[test] + fn test_diff_mode_toggle_event() { + let env = Environment::test_env(); + let mut diff = DiffComponent::new(&env, false); + diff.focus(true); + + let event = Event::Key(KeyEvent::from( + &env.key_config.keys.diff_mode_toggle, + )); + + assert!(matches!( + diff.event(&event).unwrap(), + EventState::Consumed + )); + } } diff --git a/src/components/mod.rs b/src/components/mod.rs index 51322e18ae..483d8294e6 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -53,7 +53,7 @@ pub use command::{CommandInfo, CommandText}; pub use commit_details::CommitDetailsComponent; pub use commitlist::CommitList; pub use cred::CredComponent; -pub use diff::DiffComponent; +pub use diff::{DiffComponent, DiffMode}; pub use revision_files::RevisionFilesComponent; pub use syntax_text::SyntaxTextComponent; pub use textinput::{InputType, TextInputComponent}; diff --git a/src/keys/key_list.rs b/src/keys/key_list.rs index 24a9507a49..f4185a377b 100644 --- a/src/keys/key_list.rs +++ b/src/keys/key_list.rs @@ -129,6 +129,7 @@ pub struct KeysList { pub commit: GituiKeyEvent, pub newline: GituiKeyEvent, pub goto_line: GituiKeyEvent, + pub diff_mode_toggle: GituiKeyEvent, } #[rustfmt::skip] @@ -227,6 +228,7 @@ impl Default for KeysList { 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_mode_toggle: GituiKeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty()), } } } diff --git a/src/options.rs b/src/options.rs index a80e5cb80f..bdf20e81f6 100644 --- a/src/options.rs +++ b/src/options.rs @@ -1,3 +1,4 @@ +use crate::components::DiffMode; use anyhow::Result; use asyncgit::sync::{ diff::DiffOptions, repo_dir, RepoPathRef, @@ -22,6 +23,7 @@ struct OptionsData { pub diff: DiffOptions, pub status_show_untracked: Option, pub commit_msgs: Vec, + pub diff_mode: DiffMode, } const COMMIT_MSG_HISTORY_LENGTH: usize = 20; @@ -107,6 +109,15 @@ impl Options { self.save(); } + pub const fn diff_mode(&self) -> DiffMode { + self.data.diff_mode + } + + pub fn set_diff_mode(&mut self, mode: DiffMode) { + self.data.diff_mode = mode; + self.save(); + } + pub fn add_commit_msg(&mut self, msg: &str) { self.data.commit_msgs.push(msg.to_owned()); while self.data.commit_msgs.len() > COMMIT_MSG_HISTORY_LENGTH diff --git a/src/popups/compare_commits.rs b/src/popups/compare_commits.rs index 5ff6ef87aa..abb7adb942 100644 --- a/src/popups/compare_commits.rs +++ b/src/popups/compare_commits.rs @@ -95,6 +95,12 @@ impl Component for CompareCommitsPopup { !self.diff.focused() || force_all, )); + out.push(CommandInfo::new( + strings::commands::diff_toggle_mode(&self.key_config), + true, + true, + )); + out.push(CommandInfo::new( strings::commands::diff_focus_left(&self.key_config), true, @@ -134,6 +140,11 @@ impl Component for CompareCommitsPopup { } else if key_match(e, self.key_config.keys.move_left) { self.hide_stacked(false); + } else if key_match( + e, + self.key_config.keys.diff_mode_toggle, + ) { + self.diff.toggle_diff_mode(); } return Ok(EventState::Consumed); diff --git a/src/popups/file_revlog.rs b/src/popups/file_revlog.rs index 771ae857fe..101124f08a 100644 --- a/src/popups/file_revlog.rs +++ b/src/popups/file_revlog.rs @@ -569,6 +569,11 @@ impl Component for FileRevlogPopup { self.key_config.keys.page_down, ) { self.move_selection(ScrollType::PageDown)?; + } else if key_match( + key, + self.key_config.keys.diff_mode_toggle, + ) { + self.diff.toggle_diff_mode(); } } @@ -610,6 +615,11 @@ impl Component for FileRevlogPopup { ) .order(1), ); + out.push(CommandInfo::new( + strings::commands::diff_toggle_mode(&self.key_config), + true, + true, + )); out.push(CommandInfo::new( strings::commands::diff_focus_right(&self.key_config), diff --git a/src/popups/inspect_commit.rs b/src/popups/inspect_commit.rs index 4acfe88e90..f6c71e2980 100644 --- a/src/popups/inspect_commit.rs +++ b/src/popups/inspect_commit.rs @@ -136,6 +136,12 @@ impl Component for InspectCommitPopup { true, true, )); + + out.push(CommandInfo::new( + strings::commands::diff_toggle_mode(&self.key_config), + true, + true, + )); } visibility_blocking(self) @@ -171,6 +177,11 @@ impl Component for InspectCommitPopup { } else if key_match(e, self.key_config.keys.move_left) { self.hide_stacked(false); + } else if key_match( + e, + self.key_config.keys.diff_mode_toggle, + ) { + self.diff.toggle_diff_mode(); } else if key_match( e, self.key_config.keys.open_file_tree, diff --git a/src/strings.rs b/src/strings.rs index f66a9e93f5..e651c685e2 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -834,6 +834,18 @@ pub mod commands { CMD_GROUP_DIFF, ) } + pub fn diff_toggle_mode( + key_config: &SharedKeyConfig, + ) -> CommandText { + CommandText::new( + format!( + "Toggle Diff Mode [{}]", + key_config.get_hint(key_config.keys.diff_mode_toggle), + ), + "toggle between unified and side-by-side diff", + CMD_GROUP_DIFF, + ) + } pub fn close_fuzzy_finder( key_config: &SharedKeyConfig, ) -> CommandText { diff --git a/src/tabs/status.rs b/src/tabs/status.rs index 135cf18e4e..c18f018670 100644 --- a/src/tabs/status.rs +++ b/src/tabs/status.rs @@ -804,6 +804,12 @@ impl Component for Status { true, true, )); + + out.push(CommandInfo::new( + strings::commands::diff_toggle_mode(&self.key_config), + true, + true, + )); } self.commands_nav(out, force_all); @@ -947,6 +953,12 @@ impl Component for Status { ) { self.queue.push(InternalEvent::ViewSubmodules); Ok(EventState::Consumed) + } else if key_match( + k, + self.key_config.keys.diff_mode_toggle, + ) { + self.diff.toggle_diff_mode(); + Ok(EventState::Consumed) } else { Ok(EventState::NotConsumed) }; diff --git a/vim_style_key_config.ron b/vim_style_key_config.ron index 5f3b30c3aa..d61c5a4f23 100644 --- a/vim_style_key_config.ron +++ b/vim_style_key_config.ron @@ -31,6 +31,9 @@ diff_reset_lines: Some(( code: Char('u'), modifiers: "")), diff_stage_lines: Some(( code: Char('s'), modifiers: "")), + // toggle between unified and side-by-side diff mode + diff_mode_toggle: Some(( code: Char('m'), modifiers: "")), + stashing_save: Some(( code: Char('w'), modifiers: "")), stashing_toggle_index: Some(( code: Char('m'), modifiers: "")),