mod input;
mod render;
use crate::model::{CompareInfo, DiffContent, DiffDisplayFormat};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffAction {
None,
Back,
OpenBlame {
file_path: String,
},
ShowNotification(String),
CopyToClipboard { full: bool },
ExportToFile,
CycleFormat,
}
#[derive(Debug)]
pub struct DiffView {
pub revision: String,
pub content: DiffContent,
pub scroll_offset: usize,
pub file_header_positions: Vec<usize>,
pub file_names: Vec<String>,
pub current_file_index: usize,
visible_height: usize,
pub compare_info: Option<CompareInfo>,
pub display_format: DiffDisplayFormat,
}
impl Default for DiffView {
fn default() -> Self {
Self::empty()
}
}
impl DiffView {
const DEFAULT_VISIBLE_HEIGHT: usize = 20;
pub fn empty() -> Self {
Self {
revision: String::new(),
content: DiffContent::default(),
scroll_offset: 0,
file_header_positions: Vec::new(),
file_names: Vec::new(),
current_file_index: 0,
visible_height: Self::DEFAULT_VISIBLE_HEIGHT,
compare_info: None,
display_format: DiffDisplayFormat::default(),
}
}
pub fn new(revision: String, content: DiffContent) -> Self {
let mut view = Self::empty();
view.set_content(revision, content);
view
}
pub fn new_compare(content: DiffContent, compare_info: CompareInfo) -> Self {
let mut view = Self::empty();
let revision = compare_info.from.commit_id.to_string();
view.set_content(revision, content);
view.compare_info = Some(compare_info);
view
}
pub fn set_content(&mut self, revision: String, content: DiffContent) {
use crate::model::DiffLineKind;
let (positions, names): (Vec<_>, Vec<_>) = content
.lines
.iter()
.enumerate()
.filter(|(_, line)| line.kind == DiffLineKind::FileHeader)
.map(|(i, line)| (i, line.content.clone()))
.unzip();
self.file_header_positions = positions;
self.file_names = names;
self.revision = revision;
self.content = content;
self.scroll_offset = 0;
self.current_file_index = 0;
}
#[cfg(test)]
pub fn clear(&mut self) {
self.revision.clear();
self.content = DiffContent::default();
self.scroll_offset = 0;
self.file_header_positions.clear();
self.file_names.clear();
self.current_file_index = 0;
self.visible_height = Self::DEFAULT_VISIBLE_HEIGHT;
self.display_format = DiffDisplayFormat::default();
}
pub fn cycle_format(&mut self) -> DiffDisplayFormat {
self.display_format = self.display_format.next();
self.display_format
}
pub fn current_file_name(&self) -> Option<&str> {
self.file_names
.get(self.current_file_index)
.map(|s| s.as_str())
}
pub fn file_count(&self) -> usize {
self.file_names.len()
}
pub fn description_line_count(&self) -> usize {
if self.content.description.is_empty() {
1 } else {
self.content.description.lines().count().max(1)
}
}
pub fn has_changes(&self) -> bool {
self.content.has_changes()
}
pub fn total_lines(&self) -> usize {
self.content.lines.len()
}
pub fn current_context(&self) -> String {
if self.file_count() > 0 {
let file_name = self.current_file_name().unwrap_or("(unknown)");
format!(
"{} [{}/{}]",
file_name,
self.current_file_index + 1,
self.file_count()
)
} else {
"(no files)".to_string()
}
}
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
self.update_current_file_index();
}
pub fn scroll_down(&mut self) {
let max_offset = self.max_scroll_offset();
if self.scroll_offset < max_offset {
self.scroll_offset += 1;
}
self.update_current_file_index();
}
fn max_scroll_offset(&self) -> usize {
if self.visible_height == 0 {
return 0;
}
let total = self.total_lines();
total.saturating_sub(self.visible_height)
}
pub fn scroll_half_page_up(&mut self, visible_height: usize) {
self.visible_height = visible_height;
let half = visible_height / 2;
self.scroll_offset = self.scroll_offset.saturating_sub(half);
self.update_current_file_index();
}
pub fn scroll_half_page_down(&mut self, visible_height: usize) {
self.visible_height = visible_height;
let half = visible_height / 2;
let max_offset = self.max_scroll_offset();
self.scroll_offset = (self.scroll_offset + half).min(max_offset);
self.update_current_file_index();
}
pub fn jump_to_top(&mut self) {
self.scroll_offset = 0;
self.current_file_index = 0;
}
pub fn jump_to_bottom(&mut self, visible_height: usize) {
self.visible_height = visible_height;
self.scroll_offset = self.max_scroll_offset();
self.update_current_file_index();
}
pub fn next_file(&mut self) {
if self.file_header_positions.is_empty() {
return;
}
for (i, &pos) in self.file_header_positions.iter().enumerate() {
if pos > self.scroll_offset {
self.scroll_offset = pos;
self.current_file_index = i;
return;
}
}
if let Some(&first_pos) = self.file_header_positions.first() {
self.scroll_offset = first_pos;
self.current_file_index = 0;
}
}
pub fn prev_file(&mut self) {
if self.file_header_positions.is_empty() {
return;
}
for (i, &pos) in self.file_header_positions.iter().enumerate().rev() {
if pos < self.scroll_offset {
self.scroll_offset = pos;
self.current_file_index = i;
return;
}
}
if let Some(&last_pos) = self.file_header_positions.last() {
self.scroll_offset = last_pos;
self.current_file_index = self.file_header_positions.len() - 1;
}
}
fn update_current_file_index(&mut self) {
self.current_file_index = self
.file_header_positions
.iter()
.rposition(|&pos| pos <= self.scroll_offset)
.unwrap_or(0);
}
pub fn jump_to_file(&mut self, file_path: &str) {
if let Some(idx) = self.file_names.iter().position(|name| name == file_path)
&& let Some(&pos) = self.file_header_positions.get(idx)
{
self.scroll_offset = pos;
self.current_file_index = idx;
return;
}
for (idx, name) in self.file_names.iter().enumerate() {
if let Some(new_path) = Self::extract_new_path_from_rename(name)
&& new_path == file_path
&& let Some(&pos) = self.file_header_positions.get(idx)
{
self.scroll_offset = pos;
self.current_file_index = idx;
return;
}
}
}
fn extract_new_path_from_rename(name: &str) -> Option<String> {
let brace_start = name.find('{')?;
let brace_end = name.find('}')?;
let prefix = &name[..brace_start];
let inner = &name[brace_start + 1..brace_end];
let (_, new_part) = inner.split_once(" => ")?;
Some(format!("{}{}", prefix, new_part))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{CommitId, DiffContent, DiffLine};
use crossterm::event::KeyEvent;
fn create_test_content() -> DiffContent {
let mut content = DiffContent {
commit_id: CommitId::new("abc123def456".to_string()),
author: "Test User <test@example.com>".to_string(),
timestamp: "2024-01-30 12:00:00".to_string(),
description: "Test commit".to_string(),
lines: Vec::new(),
};
content.lines.push(DiffLine::file_header("src/main.rs"));
content
.lines
.push(DiffLine::context(Some(10), Some(10), "fn main() {"));
content
.lines
.push(DiffLine::deleted(11, " println!(\"old\");"));
content
.lines
.push(DiffLine::added(11, " println!(\"new\");"));
content
.lines
.push(DiffLine::context(Some(12), Some(12), "}"));
content.lines.push(DiffLine::separator());
content.lines.push(DiffLine::file_header("src/lib.rs"));
content.lines.push(DiffLine::added(1, "pub fn hello() {}"));
content
}
#[test]
fn test_diff_view_empty() {
let view = DiffView::empty();
assert!(view.revision.is_empty());
assert!(!view.has_changes());
assert_eq!(view.file_count(), 0);
}
#[test]
fn test_diff_view_new() {
let view = DiffView::new("testchange".to_string(), create_test_content());
assert_eq!(view.revision, "testchange");
assert!(view.has_changes());
assert_eq!(view.file_count(), 2);
assert_eq!(view.file_names, vec!["src/main.rs", "src/lib.rs"]);
assert_eq!(view.file_header_positions, vec![0, 6]);
}
#[test]
fn test_diff_view_scroll() {
let mut view = DiffView::new("test".to_string(), create_test_content());
view.visible_height = 5;
assert_eq!(view.scroll_offset, 0);
view.scroll_down();
assert_eq!(view.scroll_offset, 1);
view.scroll_down();
view.scroll_down();
assert_eq!(view.scroll_offset, 3);
view.scroll_up();
assert_eq!(view.scroll_offset, 2);
view.jump_to_top();
assert_eq!(view.scroll_offset, 0);
}
#[test]
fn test_diff_view_scroll_bounds() {
let mut view = DiffView::new("test".to_string(), create_test_content());
view.scroll_up();
assert_eq!(view.scroll_offset, 0);
view.visible_height = 5;
for _ in 0..20 {
view.scroll_down();
}
let expected_max = view.total_lines().saturating_sub(view.visible_height);
assert_eq!(view.scroll_offset, expected_max);
}
#[test]
fn test_diff_view_file_jump() {
let mut view = DiffView::new("test".to_string(), create_test_content());
assert_eq!(view.current_file_index, 0);
assert_eq!(view.scroll_offset, 0);
view.next_file();
assert_eq!(view.current_file_index, 1);
assert_eq!(view.scroll_offset, 6);
view.next_file();
assert_eq!(view.current_file_index, 0);
assert_eq!(view.scroll_offset, 0);
view.prev_file();
assert_eq!(view.current_file_index, 1);
assert_eq!(view.scroll_offset, 6);
}
#[test]
fn test_diff_view_current_file_name() {
let mut view = DiffView::new("test".to_string(), create_test_content());
assert_eq!(view.current_file_name(), Some("src/main.rs"));
view.next_file();
assert_eq!(view.current_file_name(), Some("src/lib.rs"));
}
#[test]
fn test_diff_view_handle_key_scroll() {
let mut view = DiffView::new("test".to_string(), create_test_content());
let action =
view.handle_key_with_height(KeyEvent::from(crossterm::event::KeyCode::Char('j')), 5);
assert_eq!(action, DiffAction::None);
assert_eq!(view.scroll_offset, 1);
}
#[test]
fn test_diff_view_handle_key_back() {
let mut view = DiffView::empty();
let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('q')));
assert_eq!(action, DiffAction::Back);
let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Esc));
assert_eq!(action, DiffAction::Back);
}
#[test]
fn test_diff_view_half_page_scroll() {
let mut view = DiffView::new("test".to_string(), create_test_content());
view.scroll_half_page_down(4);
assert_eq!(view.scroll_offset, 2);
view.scroll_half_page_up(4);
assert_eq!(view.scroll_offset, 0);
}
#[test]
fn test_diff_view_clear() {
let mut view = DiffView::new("test".to_string(), create_test_content());
view.visible_height = 5;
view.scroll_down();
assert!(view.has_changes());
assert_eq!(view.scroll_offset, 1);
view.clear();
assert!(!view.has_changes());
assert_eq!(view.scroll_offset, 0);
assert!(view.revision.is_empty());
}
#[test]
fn test_diff_view_update_current_file_index() {
let mut view = DiffView::new("test".to_string(), create_test_content());
assert_eq!(view.current_file_index, 0);
view.scroll_offset = 7;
view.update_current_file_index();
assert_eq!(view.current_file_index, 1);
view.scroll_offset = 3;
view.update_current_file_index();
assert_eq!(view.current_file_index, 0);
}
#[test]
fn test_diff_view_current_context() {
let mut view = DiffView::new("test".to_string(), create_test_content());
assert_eq!(view.current_context(), "src/main.rs [1/2]");
view.next_file();
assert_eq!(view.current_context(), "src/lib.rs [2/2]");
}
#[test]
fn test_diff_view_current_context_empty() {
let view = DiffView::empty();
assert_eq!(view.current_context(), "(no files)");
}
#[test]
fn test_diff_view_scroll_with_zero_visible_height() {
let mut view = DiffView::new("test".to_string(), create_test_content());
view.visible_height = 0;
view.scroll_down();
assert_eq!(view.scroll_offset, 0);
view.scroll_half_page_down(0);
assert_eq!(view.scroll_offset, 0);
}
#[test]
fn test_diff_view_jump_to_file() {
let mut view = DiffView::new("test".to_string(), create_test_content());
assert_eq!(view.current_file_index, 0);
assert_eq!(view.scroll_offset, 0);
view.jump_to_file("src/lib.rs");
assert_eq!(view.current_file_index, 1);
assert_eq!(view.scroll_offset, 6);
view.jump_to_file("src/main.rs");
assert_eq!(view.current_file_index, 0);
assert_eq!(view.scroll_offset, 0);
view.jump_to_file("non_existent.rs");
assert_eq!(view.current_file_index, 0);
assert_eq!(view.scroll_offset, 0);
}
#[test]
fn test_extract_new_path_from_rename() {
assert_eq!(
DiffView::extract_new_path_from_rename("src/{old.rs => new.rs}"),
Some("src/new.rs".to_string())
);
assert_eq!(
DiffView::extract_new_path_from_rename("{old.rs => new.rs}"),
Some("new.rs".to_string())
);
assert_eq!(
DiffView::extract_new_path_from_rename("src/components/{Button.tsx => button.tsx}"),
Some("src/components/button.tsx".to_string())
);
assert_eq!(DiffView::extract_new_path_from_rename("src/main.rs"), None);
}
#[test]
fn test_compare_mode_blame_returns_notification() {
use crate::model::{ChangeId, CommitId, CompareInfo, CompareRevisionInfo};
let compare_info = CompareInfo {
from: CompareRevisionInfo {
change_id: ChangeId::new("aaaa1111".to_string()),
commit_id: CommitId::new("ff001111".to_string()),
bookmarks: vec![],
author: "user@test.com".to_string(),
timestamp: "2024-01-01".to_string(),
description: "from revision".to_string(),
},
to: CompareRevisionInfo {
change_id: ChangeId::new("bbbb2222".to_string()),
commit_id: CommitId::new("ff002222".to_string()),
bookmarks: vec![],
author: "user@test.com".to_string(),
timestamp: "2024-01-02".to_string(),
description: "to revision".to_string(),
},
};
let mut view = DiffView::new_compare(create_test_content(), compare_info);
let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('a')));
assert_eq!(
action,
DiffAction::ShowNotification("Blame is not available in compare mode".to_string())
);
}
#[test]
fn test_jump_to_file_with_rename() {
let content = DiffContent {
commit_id: CommitId::new("test123".to_string()),
author: "Test".to_string(),
timestamp: "2024-01-30".to_string(),
description: "Test".to_string(),
lines: vec![
DiffLine::file_header("src/{old.rs => new.rs}"),
DiffLine::added(1, "content"),
],
};
let mut view = DiffView::new("test".to_string(), content);
assert_eq!(view.file_names, vec!["src/{old.rs => new.rs}"]);
view.jump_to_file("src/new.rs");
assert_eq!(view.current_file_index, 0);
assert_eq!(view.scroll_offset, 0);
}
#[test]
fn test_yank_key_returns_copy_full() {
let mut view = DiffView::new("test".to_string(), create_test_content());
let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('y')));
assert_eq!(action, DiffAction::CopyToClipboard { full: true });
}
#[test]
fn test_yank_diff_key_returns_copy_diff_only() {
let mut view = DiffView::new("test".to_string(), create_test_content());
let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('Y')));
assert_eq!(action, DiffAction::CopyToClipboard { full: false });
}
#[test]
fn test_write_key_returns_export() {
let mut view = DiffView::new("test".to_string(), create_test_content());
let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('w')));
assert_eq!(action, DiffAction::ExportToFile);
}
#[test]
fn test_format_cycle_key_returns_cycle_format() {
let mut view = DiffView::new("test".to_string(), create_test_content());
let action = view.handle_key(KeyEvent::from(crossterm::event::KeyCode::Char('m')));
assert_eq!(action, DiffAction::CycleFormat);
}
#[test]
fn test_cycle_format_cycles_through_all() {
use crate::model::DiffDisplayFormat;
let mut view = DiffView::new("test".to_string(), create_test_content());
assert_eq!(view.display_format, DiffDisplayFormat::ColorWords);
let fmt = view.cycle_format();
assert_eq!(fmt, DiffDisplayFormat::Stat);
assert_eq!(view.display_format, DiffDisplayFormat::Stat);
let fmt = view.cycle_format();
assert_eq!(fmt, DiffDisplayFormat::Git);
let fmt = view.cycle_format();
assert_eq!(fmt, DiffDisplayFormat::ColorWords);
}
#[test]
fn test_clear_resets_format() {
use crate::model::DiffDisplayFormat;
let mut view = DiffView::new("test".to_string(), create_test_content());
view.cycle_format(); assert_eq!(view.display_format, DiffDisplayFormat::Stat);
view.clear();
assert_eq!(view.display_format, DiffDisplayFormat::ColorWords);
}
#[test]
fn test_format_rollback_pattern() {
use crate::model::DiffDisplayFormat;
let mut view = DiffView::new("test".to_string(), create_test_content());
let old_format = view.display_format;
assert_eq!(old_format, DiffDisplayFormat::ColorWords);
let new_format = view.cycle_format();
assert_eq!(new_format, DiffDisplayFormat::Stat);
assert_eq!(view.display_format, DiffDisplayFormat::Stat);
view.display_format = old_format;
assert_eq!(view.display_format, DiffDisplayFormat::ColorWords);
}
}