use crate::theme::Theme;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Tabs},
Frame,
};
use similar::{ChangeTag, TextDiff};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DiffViewMode {
#[default]
Unified,
SideBySide,
}
#[derive(Debug, Clone)]
pub struct DiffHunk {
pub file_path: String,
pub extension: Option<String>,
pub lines: Vec<DiffLine>,
pub old_start: usize,
pub new_start: usize,
pub operation: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DiffLine {
pub content: String,
pub line_type: DiffLineType,
pub old_line_number: Option<usize>,
pub new_line_number: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLineType {
Context,
Added,
Removed,
Header,
HunkHeader,
}
impl DiffLine {
pub fn new(content: &str, line_type: DiffLineType) -> Self {
Self {
content: content.to_string(),
line_type,
old_line_number: None,
new_line_number: None,
}
}
pub fn with_line_numbers(
content: &str,
line_type: DiffLineType,
old: Option<usize>,
new: Option<usize>,
) -> Self {
Self {
content: content.to_string(),
line_type,
old_line_number: old,
new_line_number: new,
}
}
}
pub struct DiffViewer {
pub hunks: Vec<DiffHunk>,
pub scroll: usize,
pub selected_hunk: usize,
pub view_mode: DiffViewMode,
theme: Theme,
total_lines: usize,
pub bundle_summary: Option<BundleSummary>,
}
#[derive(Debug, Clone, Default)]
pub struct BundleSummary {
pub node_id: String,
pub node_class: String,
pub files_created: usize,
pub files_modified: usize,
pub writes_count: usize,
pub diffs_count: usize,
}
impl Default for DiffViewer {
fn default() -> Self {
Self {
hunks: Vec::new(),
scroll: 0,
selected_hunk: 0,
view_mode: DiffViewMode::Unified,
theme: Theme::default(),
total_lines: 0,
bundle_summary: None,
}
}
}
impl DiffViewer {
pub fn new() -> Self {
Self::default()
}
pub fn compute_diff(&mut self, file_path: &str, old_content: &str, new_content: &str) {
self.hunks.clear();
let diff = TextDiff::from_lines(old_content, new_content);
let extension = file_path.rsplit('.').next().map(String::from);
let mut current_hunk = DiffHunk {
file_path: file_path.to_string(),
extension: extension.clone(),
lines: vec![DiffLine::new(
&format!("diff --git a/{} b/{}", file_path, file_path),
DiffLineType::Header,
)],
old_start: 1,
new_start: 1,
operation: None,
};
let mut old_line = 1usize;
let mut new_line = 1usize;
for change in diff.iter_all_changes() {
let (line_type, old_num, new_num) = match change.tag() {
ChangeTag::Delete => {
let num = old_line;
old_line += 1;
(DiffLineType::Removed, Some(num), None)
}
ChangeTag::Insert => {
let num = new_line;
new_line += 1;
(DiffLineType::Added, None, Some(num))
}
ChangeTag::Equal => {
let o = old_line;
let n = new_line;
old_line += 1;
new_line += 1;
(DiffLineType::Context, Some(o), Some(n))
}
};
let content = change.value().trim_end_matches('\n');
current_hunk.lines.push(DiffLine::with_line_numbers(
content, line_type, old_num, new_num,
));
}
if !current_hunk.lines.is_empty() {
self.hunks.push(current_hunk);
}
self.update_total_lines();
}
pub fn parse_diff(&mut self, diff_text: &str) {
self.hunks.clear();
let mut current_hunk: Option<DiffHunk> = None;
let mut old_line = 1usize;
let mut new_line = 1usize;
for line in diff_text.lines() {
if line.starts_with("diff --git") {
if let Some(hunk) = current_hunk.take() {
self.hunks.push(hunk);
}
let file_path = line.split(" b/").nth(1).unwrap_or("unknown").to_string();
let extension = file_path.rsplit('.').next().map(String::from);
current_hunk = Some(DiffHunk {
file_path,
extension,
lines: vec![DiffLine::new(line, DiffLineType::Header)],
old_start: 1,
new_start: 1,
operation: None,
});
old_line = 1;
new_line = 1;
} else if line.starts_with("---") || line.starts_with("+++") {
if let Some(ref mut hunk) = current_hunk {
hunk.lines.push(DiffLine::new(line, DiffLineType::Header));
}
} else if line.starts_with("@@") {
if let Some(ref mut hunk) = current_hunk {
hunk.lines
.push(DiffLine::new(line, DiffLineType::HunkHeader));
if let Some(nums) = parse_hunk_header(line) {
old_line = nums.0;
new_line = nums.2;
hunk.old_start = nums.0;
hunk.new_start = nums.2;
}
}
} else if let Some(ref mut hunk) = current_hunk {
let (line_type, old_num, new_num) = if line.starts_with('+') {
let n = new_line;
new_line += 1;
(DiffLineType::Added, None, Some(n))
} else if line.starts_with('-') {
let o = old_line;
old_line += 1;
(DiffLineType::Removed, Some(o), None)
} else {
let o = old_line;
let n = new_line;
old_line += 1;
new_line += 1;
(DiffLineType::Context, Some(o), Some(n))
};
let content = if line.len() > 1
&& (line.starts_with('+') || line.starts_with('-') || line.starts_with(' '))
{
&line[1..]
} else {
line
};
hunk.lines.push(DiffLine::with_line_numbers(
content, line_type, old_num, new_num,
));
}
}
if let Some(hunk) = current_hunk {
self.hunks.push(hunk);
}
self.update_total_lines();
}
pub fn clear(&mut self) {
self.hunks.clear();
self.scroll = 0;
self.selected_hunk = 0;
self.total_lines = 0;
self.bundle_summary = None;
}
fn update_total_lines(&mut self) {
self.total_lines = self.hunks.iter().map(|h| h.lines.len()).sum();
}
pub fn toggle_view_mode(&mut self) {
self.view_mode = match self.view_mode {
DiffViewMode::Unified => DiffViewMode::SideBySide,
DiffViewMode::SideBySide => DiffViewMode::Unified,
};
}
pub fn scroll_up(&mut self) {
self.scroll = self.scroll.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.scroll = self.scroll.saturating_add(1);
}
pub fn page_up(&mut self, lines: usize) {
self.scroll = self.scroll.saturating_sub(lines);
}
pub fn page_down(&mut self, lines: usize) {
self.scroll = self.scroll.saturating_add(lines);
}
pub fn next_hunk(&mut self) {
if self.selected_hunk < self.hunks.len().saturating_sub(1) {
self.selected_hunk += 1;
let mut line_offset = 0;
for i in 0..self.selected_hunk {
line_offset += self.hunks[i].lines.len();
}
self.scroll = line_offset;
}
}
pub fn prev_hunk(&mut self) {
if self.selected_hunk > 0 {
self.selected_hunk -= 1;
let mut line_offset = 0;
for i in 0..self.selected_hunk {
line_offset += self.hunks[i].lines.len();
}
self.scroll = line_offset;
}
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(5)])
.split(area);
let tab_titles = vec!["Unified", "Side-by-Side"];
let tabs = Tabs::new(tab_titles)
.block(
Block::default()
.borders(Borders::ALL)
.title("View Mode")
.border_style(self.theme.border),
)
.select(match self.view_mode {
DiffViewMode::Unified => 0,
DiffViewMode::SideBySide => 1,
})
.style(Style::default().fg(Color::White))
.highlight_style(self.theme.highlight);
frame.render_widget(tabs, chunks[0]);
match self.view_mode {
DiffViewMode::Unified => self.render_unified(frame, chunks[1]),
DiffViewMode::SideBySide => self.render_side_by_side(frame, chunks[1]),
}
}
fn render_unified(&self, frame: &mut Frame, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
if let Some(ref summary) = self.bundle_summary {
lines.push(Line::from(vec![
Span::styled(" Node: ", Style::default().fg(Color::DarkGray)),
Span::styled(
&summary.node_id,
Style::default()
.fg(Color::Rgb(129, 212, 250))
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" [{}]", summary.node_class),
Style::default().fg(Color::Rgb(179, 157, 219)),
),
]));
lines.push(Line::from(vec![Span::styled(
format!(
" {} created, {} modified — {} writes, {} diffs",
summary.files_created,
summary.files_modified,
summary.writes_count,
summary.diffs_count
),
Style::default().fg(Color::Rgb(158, 158, 158)),
)]));
lines.push(Line::from(""));
}
for (hunk_idx, hunk) in self.hunks.iter().enumerate() {
if let Some(ref op) = hunk.operation {
let op_color = match op.as_str() {
"created" => Color::Rgb(102, 187, 106),
"modified" => Color::Rgb(255, 183, 77),
_ => Color::White,
};
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", op),
Style::default().fg(op_color).add_modifier(Modifier::BOLD),
),
Span::styled(
&hunk.file_path,
Style::default().fg(Color::Rgb(129, 212, 250)),
),
]));
}
for line in &hunk.lines {
let (fg_color, bg_color, prefix) = match line.line_type {
DiffLineType::Added => {
(Color::Rgb(200, 255, 200), Some(Color::Rgb(30, 50, 30)), "+")
}
DiffLineType::Removed => {
(Color::Rgb(255, 200, 200), Some(Color::Rgb(50, 30, 30)), "-")
}
DiffLineType::Header => (Color::Rgb(129, 212, 250), None, " "),
DiffLineType::HunkHeader => {
(Color::Rgb(186, 104, 200), Some(Color::Rgb(40, 30, 50)), " ")
}
DiffLineType::Context => (Color::Rgb(180, 180, 180), None, " "),
};
let line_nums = match (line.old_line_number, line.new_line_number) {
(Some(o), Some(n)) => format!("{:>4} {:>4} ", o, n),
(Some(o), None) => format!("{:>4} ", o),
(None, Some(n)) => format!(" {:>4} ", n),
(None, None) => " ".to_string(),
};
let mut spans = vec![
Span::styled(line_nums, Style::default().fg(Color::Rgb(100, 100, 100))),
Span::styled(format!("{} ", prefix), Style::default().fg(fg_color)),
];
let content_style = if hunk_idx == self.selected_hunk {
Style::default().fg(fg_color).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(fg_color)
};
let content_style = if let Some(bg) = bg_color {
content_style.bg(bg)
} else {
content_style
};
spans.push(Span::styled(&line.content, content_style));
lines.push(Line::from(spans));
}
}
let visible_lines = area.height.saturating_sub(2) as usize;
let max_scroll = self.total_lines.saturating_sub(visible_lines);
let scroll = self.scroll.min(max_scroll);
let stats = self.compute_stats();
let title = format!(
"📝 Diff: {} files, +{} -{} ({} hunks)",
self.hunks.len(),
stats.additions,
stats.deletions,
self.hunks.len()
);
let para = Paragraph::new(lines)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(self.theme.border),
)
.scroll((scroll as u16, 0));
frame.render_widget(para, area);
let scrollbar = Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓"));
let mut scrollbar_state = ScrollbarState::new(self.total_lines).position(scroll);
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
}
fn render_side_by_side(&self, frame: &mut Frame, area: Rect) {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let old_lines: Vec<Line> = self
.hunks
.iter()
.flat_map(|hunk| {
hunk.lines.iter().filter_map(|line| {
match line.line_type {
DiffLineType::Removed | DiffLineType::Context => {
let num = line
.old_line_number
.map(|n| format!("{:>4} ", n))
.unwrap_or_else(|| " ".to_string());
let style = match line.line_type {
DiffLineType::Removed => Style::default()
.fg(Color::Rgb(255, 200, 200))
.bg(Color::Rgb(50, 30, 30)),
_ => Style::default().fg(Color::Rgb(180, 180, 180)),
};
Some(Line::from(vec![
Span::styled(num, Style::default().fg(Color::Rgb(100, 100, 100))),
Span::styled(&line.content, style),
]))
}
DiffLineType::Added => Some(Line::from("")), _ => None,
}
})
})
.collect();
let new_lines: Vec<Line> = self
.hunks
.iter()
.flat_map(|hunk| {
hunk.lines.iter().filter_map(|line| {
match line.line_type {
DiffLineType::Added | DiffLineType::Context => {
let num = line
.new_line_number
.map(|n| format!("{:>4} ", n))
.unwrap_or_else(|| " ".to_string());
let style = match line.line_type {
DiffLineType::Added => Style::default()
.fg(Color::Rgb(200, 255, 200))
.bg(Color::Rgb(30, 50, 30)),
_ => Style::default().fg(Color::Rgb(180, 180, 180)),
};
Some(Line::from(vec![
Span::styled(num, Style::default().fg(Color::Rgb(100, 100, 100))),
Span::styled(&line.content, style),
]))
}
DiffLineType::Removed => Some(Line::from("")), _ => None,
}
})
})
.collect();
let visible = area.height.saturating_sub(2) as usize;
let scroll = self.scroll.min(old_lines.len().saturating_sub(visible));
let old_para = Paragraph::new(old_lines)
.block(Block::default().title("Old").borders(Borders::ALL))
.scroll((scroll as u16, 0));
frame.render_widget(old_para, columns[0]);
let new_para = Paragraph::new(new_lines)
.block(Block::default().title("New").borders(Borders::ALL))
.scroll((scroll as u16, 0));
frame.render_widget(new_para, columns[1]);
}
fn compute_stats(&self) -> DiffStats {
let mut stats = DiffStats::default();
for hunk in &self.hunks {
for line in &hunk.lines {
match line.line_type {
DiffLineType::Added => stats.additions += 1,
DiffLineType::Removed => stats.deletions += 1,
_ => {}
}
}
}
stats
}
}
fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
return None;
}
let old_range = parts.get(1)?;
let new_range = parts.get(2)?;
let parse_range = |s: &str| -> Option<(usize, usize)> {
let s = s.trim_start_matches(['-', '+'].as_ref());
let parts: Vec<&str> = s.split(',').collect();
let start = parts.first()?.parse().ok()?;
let count = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
Some((start, count))
};
let (old_start, old_count) = parse_range(old_range)?;
let (new_start, new_count) = parse_range(new_range)?;
Some((old_start, old_count, new_start, new_count))
}
#[derive(Default)]
struct DiffStats {
additions: usize,
deletions: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_diff() {
let mut viewer = DiffViewer::new();
viewer.compute_diff(
"test.rs",
"line1\nline2\nline3\n",
"line1\nmodified\nline3\nnew line\n",
);
assert_eq!(viewer.hunks.len(), 1);
let stats = viewer.compute_stats();
assert!(stats.additions > 0);
assert!(stats.deletions > 0);
}
#[test]
fn test_parse_hunk_header() {
let result = parse_hunk_header("@@ -1,5 +1,7 @@");
assert_eq!(result, Some((1, 5, 1, 7)));
}
#[test]
fn test_bundle_summary_default() {
let viewer = DiffViewer::new();
assert!(viewer.bundle_summary.is_none());
}
#[test]
fn test_bundle_summary_set_and_clear() {
let mut viewer = DiffViewer::new();
viewer.bundle_summary = Some(BundleSummary {
node_id: "node-1".to_string(),
node_class: "Implementation".to_string(),
files_created: 2,
files_modified: 3,
writes_count: 5,
diffs_count: 3,
});
assert!(viewer.bundle_summary.is_some());
viewer.clear();
assert!(viewer.bundle_summary.is_none());
}
#[test]
fn test_hunk_operation_label() {
let mut viewer = DiffViewer::new();
viewer.compute_diff("src/new.rs", "", "fn main() {}\n");
assert_eq!(viewer.hunks.len(), 1);
viewer.hunks[0].operation = Some("created".to_string());
assert_eq!(viewer.hunks[0].operation.as_deref(), Some("created"));
}
#[test]
fn test_parse_diff_multi_file() {
let mut viewer = DiffViewer::new();
let diff_text = "\
diff --git a/src/a.rs b/src/a.rs
--- a/src/a.rs
+++ b/src/a.rs
@@ -1,3 +1,4 @@
line1
+new line
line2
line3
diff --git a/src/b.rs b/src/b.rs
--- a/src/b.rs
+++ b/src/b.rs
@@ -1,2 +1,2 @@
-old
+new
same
";
viewer.parse_diff(diff_text);
assert_eq!(viewer.hunks.len(), 2);
assert_eq!(viewer.hunks[0].file_path, "src/a.rs");
assert_eq!(viewer.hunks[1].file_path, "src/b.rs");
}
}