Skip to main content

mockforge_tui/widgets/
diff_viewer.rs

1//! Side-by-side or inline diff viewer widget.
2
3use ratatui::{
4    layout::Rect,
5    style::Style,
6    text::{Line, Span},
7    widgets::{Block, Borders, Paragraph},
8    Frame,
9};
10
11use crate::theme::Theme;
12
13/// A single line in a diff — added, removed, or unchanged.
14#[derive(Debug, Clone)]
15pub enum DiffLine {
16    Added(String),
17    Removed(String),
18    Unchanged(String),
19}
20
21/// Render a unified diff view with color-coded add/remove lines.
22pub fn render(
23    frame: &mut Frame,
24    area: Rect,
25    title: &str,
26    diff_lines: &[DiffLine],
27    scroll_offset: u16,
28) {
29    let block = Block::default()
30        .title(format!(" {title} "))
31        .title_style(Theme::title())
32        .borders(Borders::ALL)
33        .border_style(Theme::dim())
34        .style(Theme::surface());
35
36    let lines: Vec<Line> = diff_lines
37        .iter()
38        .map(|dl| match dl {
39            DiffLine::Added(text) => {
40                Line::from(Span::styled(format!("+ {text}"), Style::default().fg(Theme::GREEN)))
41            }
42            DiffLine::Removed(text) => {
43                Line::from(Span::styled(format!("- {text}"), Style::default().fg(Theme::RED)))
44            }
45            DiffLine::Unchanged(text) => {
46                Line::from(Span::styled(format!("  {text}"), Style::default().fg(Theme::FG)))
47            }
48        })
49        .collect();
50
51    if lines.is_empty() {
52        let empty = Paragraph::new(Span::styled("  No differences", Theme::dim()))
53            .block(block)
54            .scroll((scroll_offset, 0));
55        frame.render_widget(empty, area);
56    } else {
57        let paragraph = Paragraph::new(lines).block(block).scroll((scroll_offset, 0));
58        frame.render_widget(paragraph, area);
59    }
60}
61
62/// Parse a unified diff string into `DiffLine` entries.
63pub fn parse_unified_diff(text: &str) -> Vec<DiffLine> {
64    text.lines()
65        .map(|line| {
66            if let Some(rest) = line.strip_prefix('+') {
67                DiffLine::Added(rest.to_string())
68            } else if let Some(rest) = line.strip_prefix('-') {
69                DiffLine::Removed(rest.to_string())
70            } else {
71                DiffLine::Unchanged(line.strip_prefix(' ').unwrap_or(line).to_string())
72            }
73        })
74        .collect()
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn parse_empty_diff() {
83        let result = parse_unified_diff("");
84        assert!(result.is_empty());
85    }
86
87    #[test]
88    fn parse_additions_and_removals() {
89        let diff = "+added line\n-removed line\n unchanged line";
90        let result = parse_unified_diff(diff);
91        assert_eq!(result.len(), 3);
92        assert!(matches!(&result[0], DiffLine::Added(s) if s == "added line"));
93        assert!(matches!(&result[1], DiffLine::Removed(s) if s == "removed line"));
94        assert!(matches!(&result[2], DiffLine::Unchanged(s) if s == "unchanged line"));
95    }
96
97    #[test]
98    fn parse_mixed_diff() {
99        let diff = " context\n+new\n-old\n context2";
100        let result = parse_unified_diff(diff);
101        assert_eq!(result.len(), 4);
102        assert!(matches!(&result[0], DiffLine::Unchanged(s) if s == "context"));
103        assert!(matches!(&result[1], DiffLine::Added(s) if s == "new"));
104        assert!(matches!(&result[2], DiffLine::Removed(s) if s == "old"));
105        assert!(matches!(&result[3], DiffLine::Unchanged(s) if s == "context2"));
106    }
107}