mirror of
https://github.com/gitui-org/gitui
synced 2026-05-23 08:58:21 +00:00
Merge 5cf6719003 into 8619c07f3f
This commit is contained in:
commit
26c58b5c55
11 changed files with 793 additions and 37 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<u32>,
|
||||
right_content: String,
|
||||
right_line_num: Option<u32>,
|
||||
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<usize> {
|
||||
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<Line<'_>> {
|
||||
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<SideBySideLine> {
|
||||
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<Line> = 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<Line> = 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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ShowUntrackedFilesConfig>,
|
||||
pub commit_msgs: Vec<String>,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: "")),
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue