use crate::syntax::SyntaxHighlighter;
use tracing::debug;
use ratatui::{
buffer::Buffer,
layout::Rect,
prelude::Widget,
style::{Color, Style},
text::{Line, Span},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffType {
Addition,
Deletion,
Context,
Header,
}
#[derive(Debug, Clone)]
pub struct DiffLine {
pub line_type: DiffType,
pub content: String,
pub old_line: Option<usize>,
pub new_line: Option<usize>,
}
impl DiffLine {
pub fn new(line_type: DiffType, content: String) -> Self {
Self {
line_type,
content,
old_line: None,
new_line: None,
}
}
pub fn with_old_line(mut self, line: usize) -> Self {
self.old_line = Some(line);
self
}
pub fn with_new_line(mut self, line: usize) -> Self {
self.new_line = Some(line);
self
}
}
#[derive(Clone)]
pub struct DiffView {
lines: Vec<DiffLine>,
scroll_offset: usize,
highlighter: SyntaxHighlighter,
}
impl DiffView {
fn detect_language_from_diff(&self) -> &str {
for line in &self.lines {
if line.content.ends_with(".rs") || line.content.contains(".rs ") {
return "rust";
}
if line.content.ends_with(".py") || line.content.contains(".py ") {
return "python";
}
if line.content.ends_with(".js") || line.content.contains(".js ") {
return "javascript";
}
if line.content.ends_with(".ts") || line.content.contains(".ts ") {
return "typescript";
}
if line.content.ends_with(".go") || line.content.contains(".go ") {
return "go";
}
if line.content.ends_with(".java") || line.content.contains(".java ") {
return "java";
}
if line.content.ends_with(".cpp")
|| line.content.contains(".cpp ")
|| line.content.ends_with(".cc")
|| line.content.contains(".cc ")
|| line.content.ends_with(".cxx")
|| line.content.contains(".cxx ")
{
return "cpp";
}
if line.content.ends_with(".c")
&& !line.content.ends_with(".cpp")
&& !line.content.ends_with(".cxx")
{
return "c";
}
if line.content.ends_with(".json") || line.content.contains(".json ") {
return "json";
}
if line.content.ends_with(".yaml")
|| line.content.contains(".yaml ")
|| line.content.ends_with(".yml")
|| line.content.contains(".yml ")
{
return "yaml";
}
if line.content.ends_with(".toml") || line.content.contains(".toml ") {
return "toml";
}
if line.content.ends_with(".sh")
|| line.content.contains(".sh ")
|| line.content.ends_with(".bash")
|| line.content.contains(".bash ")
{
return "bash";
}
}
""
}
pub fn new() -> Self {
debug!(component = %"DiffView", "Component created");
Self {
lines: Vec::new(),
scroll_offset: 0,
highlighter: SyntaxHighlighter::new().expect("Failed to initialize syntax highlighter"),
}
}
pub fn from_diff(diff_text: &str) -> Self {
debug!(component = %"DiffView", "Component created");
Self {
lines: parse_diff(diff_text),
scroll_offset: 0,
highlighter: SyntaxHighlighter::new().expect("Failed to initialize syntax highlighter"),
}
}
pub fn set_diff(&mut self, diff_text: &str) {
self.lines = parse_diff(diff_text);
self.scroll_offset = 0;
}
pub fn len(&self) -> usize {
self.lines.len()
}
pub fn is_empty(&self) -> bool {
self.lines.is_empty()
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn scroll_up(&mut self) {
if self.scroll_offset > 0 {
self.scroll_offset -= 1;
}
}
pub fn scroll_down(&mut self) {
if self.scroll_offset < self.lines.len().saturating_sub(1) {
self.scroll_offset += 1;
}
}
pub fn page_up(&mut self, page_size: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
}
pub fn page_down(&mut self, page_size: usize) {
let max_offset = self.lines.len().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + page_size).min(max_offset);
}
pub fn scroll_to_top(&mut self) {
self.scroll_offset = 0;
}
pub fn scroll_to_bottom(&mut self) {
self.scroll_offset = self.lines.len().saturating_sub(1);
}
}
impl Default for DiffView {
fn default() -> Self {
Self::new()
}
}
impl Widget for DiffView {
fn render(self, area: Rect, buf: &mut Buffer) {
let max_line = self
.lines
.iter()
.filter_map(|l| l.old_line.or(l.new_line))
.max();
let line_num_width = max_line.map_or(0, |n| n.to_string().len()) + 1;
let visible_count = area.height as usize;
let start_idx = self.scroll_offset;
let end_idx = (start_idx + visible_count).min(self.lines.len());
let lang = self.detect_language_from_diff();
for (i, line) in self.lines[start_idx..end_idx].iter().enumerate() {
let y = area.y + i as u16;
if y >= area.bottom() {
break;
}
let old_line_str = line.old_line.map(|n| n.to_string()).unwrap_or_default();
let new_line_str = line.new_line.map(|n| n.to_string()).unwrap_or_default();
let style = match line.line_type {
DiffType::Addition => Style::default().fg(Color::Green),
DiffType::Deletion => Style::default().fg(Color::Red),
DiffType::Context => Style::default().fg(Color::White),
DiffType::Header => Style::default().fg(Color::Yellow),
};
let line_num_text = if old_line_str.is_empty() && new_line_str.is_empty() {
format!("{:>line_num_width$} ", "")
} else if old_line_str.is_empty() {
format!("{:>line_num_width$} ", new_line_str)
} else {
format!("{:>line_num_width$} ", old_line_str)
};
let content_max_width = (area.width as usize).saturating_sub(line_num_width + 2);
let char_count = line.content.chars().count();
let content = if char_count > content_max_width {
let truncated: String = line
.content
.chars()
.take(content_max_width.saturating_sub(3))
.collect();
format!("{truncated}...")
} else {
line.content.clone()
};
let line_num_spans = vec![Span::styled(
line_num_text,
Style::default().fg(Color::DarkGray),
)];
let line_num_line = Line::from(line_num_spans);
buf.set_line(area.x, y, &line_num_line, line_num_width as u16);
let content_start_x = area.x + line_num_width as u16;
let text_spans = if matches!(line.line_type, DiffType::Addition | DiffType::Deletion)
&& !lang.is_empty()
&& !line.content.is_empty()
{
let line_content = format!("{}\n", line.content);
match self.highlighter.highlight_to_spans(&line_content, lang) {
Ok(highlighted_lines) if !highlighted_lines.is_empty() => {
highlighted_lines[0].clone()
}
_ => vec![Span::styled(content, style)],
}
} else {
vec![Span::styled(content, style)]
};
let text_line = Line::from(text_spans);
buf.set_line(
content_start_x,
y,
&text_line,
area.width - line_num_width as u16,
);
}
}
}
impl Widget for &DiffView {
fn render(self, area: Rect, buf: &mut Buffer) {
self.clone().render(area, buf);
}
}
pub fn parse_diff(diff_text: &str) -> Vec<DiffLine> {
let mut lines = Vec::new();
let mut old_line: Option<usize> = None;
let mut new_line: Option<usize> = None;
let mut in_hunk = false;
for line in diff_text.lines() {
if line.starts_with("---") {
lines.push(DiffLine::new(DiffType::Header, line.to_string()));
in_hunk = false;
} else if line.starts_with("+++") {
lines.push(DiffLine::new(DiffType::Header, line.to_string()));
in_hunk = false;
} else if line.starts_with("@@") {
lines.push(DiffLine::new(DiffType::Header, line.to_string()));
in_hunk = true;
if let Some(hunk_part) = line.split("@@").nth(1) {
let parts: Vec<&str> = hunk_part.split_whitespace().collect();
for part in parts {
if part.starts_with('-') {
if let Some(line_num) = part.trim_start_matches('-').split(',').next() {
old_line = line_num.parse().ok();
}
} else if part.starts_with('+') {
if let Some(line_num) = part.trim_start_matches('+').split(',').next() {
new_line = line_num.parse().ok();
}
}
}
}
} else if in_hunk {
if let Some(stripped) = line.strip_prefix('+') {
let content = stripped.to_string();
let _line_num = new_line.map(|n| {
n + lines
.iter()
.filter(|l| l.line_type == DiffType::Addition)
.count()
});
lines.push(
DiffLine::new(DiffType::Addition, content).with_new_line(new_line.unwrap_or(0)),
);
new_line = new_line.map(|n| n + 1);
} else if let Some(stripped) = line.strip_prefix('-') {
let content = stripped.to_string();
lines.push(
DiffLine::new(DiffType::Deletion, content).with_old_line(old_line.unwrap_or(0)),
);
old_line = old_line.map(|n| n + 1);
} else if let Some(stripped) = line.strip_prefix(' ') {
let content = stripped.to_string();
lines.push(
DiffLine::new(DiffType::Context, content)
.with_old_line(old_line.unwrap_or(0))
.with_new_line(new_line.unwrap_or(0)),
);
old_line = old_line.map(|n| n + 1);
new_line = new_line.map(|n| n + 1);
} else {
lines.push(DiffLine::new(DiffType::Context, line.to_string()));
}
} else {
lines.push(DiffLine::new(DiffType::Header, line.to_string()));
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diff_view_new() {
let view = DiffView::new();
assert_eq!(view.len(), 0);
assert!(view.is_empty());
assert_eq!(view.scroll_offset(), 0);
}
#[test]
fn test_diff_view_default() {
let view = DiffView::default();
assert_eq!(view.len(), 0);
assert!(view.is_empty());
}
#[test]
fn test_parse_diff() {
let diff_text = r#"--- a/file.txt
+++ b/file.txt
@@ -1,3 +1,4 @@
line 1
-deleted line
+added line
line 2
+new line"#;
let lines = parse_diff(diff_text);
assert_eq!(lines.len(), 8);
assert_eq!(lines[0].line_type, DiffType::Header);
assert!(lines[0].content.contains("---"));
let hunk_idx = lines
.iter()
.position(|l| l.line_type == DiffType::Header && l.content.starts_with("@@"));
assert!(hunk_idx.is_some());
let context_idx = lines
.iter()
.position(|l| l.line_type == DiffType::Context && l.content == "line 1");
assert!(context_idx.is_some());
let deletion_idx = lines
.iter()
.position(|l| l.line_type == DiffType::Deletion && l.content == "deleted line");
assert!(deletion_idx.is_some());
let addition_idx = lines
.iter()
.position(|l| l.line_type == DiffType::Addition && l.content == "added line");
assert!(addition_idx.is_some());
let new_addition_idx = lines
.iter()
.position(|l| l.line_type == DiffType::Addition && l.content == "new line");
assert!(new_addition_idx.is_some());
}
#[test]
fn test_diff_view_from_diff() {
let diff_text = "--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-context\n+new";
let view = DiffView::from_diff(diff_text);
assert_eq!(view.len(), 5);
assert!(!view.is_empty());
}
#[test]
fn test_diff_view_set_diff() {
let mut view = DiffView::new();
assert!(view.is_empty());
let diff_text = "--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-old\n+new";
view.set_diff(diff_text);
assert_eq!(view.len(), 5);
assert!(!view.is_empty());
assert_eq!(view.scroll_offset(), 0);
}
#[test]
fn test_diff_view_scroll() {
let mut view = DiffView::from_diff(
"--- a/file.txt\n+++ b/file.txt\n@@ -1,5 +1,5 @@\n line 1\n line 2\n line 3\n line 4\n line 5",
);
assert_eq!(view.scroll_offset(), 0);
view.scroll_down();
assert_eq!(view.scroll_offset(), 1);
view.scroll_down();
assert_eq!(view.scroll_offset(), 2);
view.scroll_up();
assert_eq!(view.scroll_offset(), 1);
view.scroll_up();
assert_eq!(view.scroll_offset(), 0);
view.scroll_up();
assert_eq!(view.scroll_offset(), 0);
}
#[test]
fn test_diff_view_page_scroll() {
let mut view = DiffView::from_diff(
"--- a/file.txt\n+++ b/file.txt\n@@ -1,20 +1,20 @@\n line 1\n line 2\n line 3\n line 4\n line 5\n line 6\n line 7\n line 8\n line 9\n line 10\n line 11\n line 12\n line 13\n line 14\n line 15\n line 16\n line 17\n line 18\n line 19\n line 20",
);
assert_eq!(view.scroll_offset(), 0);
view.page_down(10);
assert_eq!(view.scroll_offset(), 10);
view.page_down(10);
assert_eq!(view.scroll_offset(), 20);
view.page_up(10);
assert_eq!(view.scroll_offset(), 10);
view.page_up(10);
assert_eq!(view.scroll_offset(), 0);
view.page_up(10);
assert_eq!(view.scroll_offset(), 0);
}
#[test]
fn test_diff_view_scroll_to_top_bottom() {
let mut view = DiffView::from_diff(
"--- a/file.txt\n+++ b/file.txt\n@@ -1,10 +1,10 @@\n line 1\n line 2\n line 3\n line 4\n line 5\n line 6\n line 7\n line 8\n line 9\n line 10",
);
view.scroll_down();
view.scroll_down();
assert_eq!(view.scroll_offset(), 2);
view.scroll_to_top();
assert_eq!(view.scroll_offset(), 0);
view.scroll_to_bottom();
assert_eq!(view.scroll_offset(), 12); }
#[test]
fn test_diff_view_large_file() {
let mut diff_text =
String::from("--- a/large.txt\n+++ b/large.txt\n@@ -1,10000 +1,10000 @@\n");
for i in 1..=10000 {
diff_text.push_str(&format!(" line {}\n", i));
}
let view = DiffView::from_diff(&diff_text);
assert_eq!(view.len(), 10003); assert!(!view.is_empty());
let mut view = DiffView::from_diff(&diff_text);
view.page_down(100);
assert_eq!(view.scroll_offset(), 100);
view.page_up(50);
assert_eq!(view.scroll_offset(), 50);
view.scroll_to_bottom();
assert_eq!(view.scroll_offset(), 10002);
}
#[test]
fn test_diff_line_new() {
let line = DiffLine::new(DiffType::Addition, "test content".to_string());
assert_eq!(line.line_type, DiffType::Addition);
assert_eq!(line.content, "test content");
assert_eq!(line.old_line, None);
assert_eq!(line.new_line, None);
}
#[test]
fn test_diff_line_with_line_numbers() {
let line = DiffLine::new(DiffType::Addition, "test".to_string())
.with_old_line(10)
.with_new_line(15);
assert_eq!(line.old_line, Some(10));
assert_eq!(line.new_line, Some(15));
}
#[test]
fn test_parse_empty_diff() {
let lines = parse_diff("");
assert!(lines.is_empty());
}
#[test]
fn test_parse_diff_only_headers() {
let diff_text = "--- a/file.txt\n+++ b/file.txt";
let lines = parse_diff(diff_text);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].line_type, DiffType::Header);
assert_eq!(lines[1].line_type, DiffType::Header);
}
#[test]
fn test_diff_view_render() {
let view = DiffView::from_diff(
"--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n line 1\n-old\n+new\n line 3",
);
assert_eq!(view.len(), 7);
}
}