Skip to main content

recursive/tui/ui/
diff.rs

1//! Diff block renderer.
2//!
3//! Renders a [`TranscriptBlock::Diff`] as a header line (`📝 path`) plus
4//! one line per [`DiffLine`] with `+` / `-` lines coloured green / red
5//! and unchanged context lines greyed. When the diff has no hunks
6//! (typically the synthesised "Created/Updated" stub for `write_file`)
7//! we render a single muted line summarising the path.
8//!
9//! V4A patch parsing lives in [`crate::tui::app::parse_v4a_patch`]; this
10//! module is purely the visual layer.
11
12use ratatui::prelude::*;
13
14use crate::tui::app::{DiffHunk, DiffLineKind};
15
16/// Header line for a Diff block: `  📝 <path>`.
17pub fn header_line(path: &str) -> Line<'static> {
18    Line::from(vec![
19        Span::raw("  "),
20        Span::styled("📝", Style::default().fg(Color::Magenta)),
21        Span::raw(" "),
22        Span::styled(path.to_string(), Style::default().fg(Color::White)),
23    ])
24}
25
26/// Render the body lines of a Diff block (no header).
27pub fn body_lines(hunks: &[DiffHunk]) -> Vec<Line<'static>> {
28    let mut out = Vec::new();
29    for hunk in hunks {
30        for dl in &hunk.lines {
31            let (sigil, color) = match dl.kind {
32                DiffLineKind::Add => ("+", Color::Green),
33                DiffLineKind::Remove => ("-", Color::Red),
34                DiffLineKind::Context => (" ", Color::Gray),
35            };
36            out.push(Line::from(vec![
37                Span::styled("    │ ".to_string(), Style::default().fg(Color::Gray)),
38                Span::styled(sigil.to_string(), Style::default().fg(color)),
39                Span::raw(" "),
40                Span::styled(dl.text.clone(), Style::default().fg(color)),
41            ]));
42        }
43    }
44    out
45}
46
47/// "No hunks" stub line used when a `write_file` only produced a
48/// path summary.
49pub fn empty_stub_line(path: &str) -> Line<'static> {
50    Line::from(vec![
51        Span::styled("    │ ".to_string(), Style::default().fg(Color::Gray)),
52        Span::styled(
53            format!("Updated {path}"),
54            Style::default()
55                .fg(Color::Gray)
56                .add_modifier(Modifier::ITALIC),
57        ),
58    ])
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::tui::app::DiffLine;
65
66    fn span_style_colors(line: &Line) -> Vec<Color> {
67        line.spans.iter().filter_map(|s| s.style.fg).collect()
68    }
69
70    #[test]
71    fn diff_renders_plus_minus_with_colors() {
72        let hunks = vec![DiffHunk {
73            lines: vec![
74                DiffLine {
75                    kind: DiffLineKind::Add,
76                    text: "new line".into(),
77                },
78                DiffLine {
79                    kind: DiffLineKind::Remove,
80                    text: "old line".into(),
81                },
82                DiffLine {
83                    kind: DiffLineKind::Context,
84                    text: "ctx".into(),
85                },
86            ],
87        }];
88        let lines = body_lines(&hunks);
89        assert_eq!(lines.len(), 3);
90        assert!(span_style_colors(&lines[0]).contains(&Color::Green));
91        assert!(span_style_colors(&lines[1]).contains(&Color::Red));
92        // context line: at least one DarkGray span (the gutter)
93        assert!(span_style_colors(&lines[2]).contains(&Color::Gray));
94    }
95
96    #[test]
97    fn header_line_contains_path() {
98        let line = header_line("src/agent.rs");
99        let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
100        assert!(text.contains("src/agent.rs"));
101        assert!(text.contains("📝"));
102    }
103}