Skip to main content

koda_cli/
diff_render.rs

1//! Diff preview renderer — native ratatui `Line`/`Span` output.
2//!
3//! Takes structured [`DiffPreview`] data from koda-core and produces
4//! `Vec<Line<'static>>` with:
5//! - Proper unified diff with hunk headers and context lines
6//! - Syntax highlighting with cross-hunk context (via pre-highlighted files)
7//! - Dark background tint for diff lines (red removed, green added)
8//! - Gutter metadata for NoSelect copy support
9
10use crate::highlight;
11use koda_core::preview::{
12    DeleteDirPreview, DeleteFilePreview, DiffLine, DiffPreview, DiffTag, UnifiedDiffPreview,
13    WriteNewPreview,
14};
15use ratatui::{
16    style::{Color, Modifier, Style},
17    text::{Line, Span},
18};
19
20// ── Styles ────────────────────────────────────────────────────
21
22const LINE_RED_BG: Color = Color::Rgb(50, 0, 0);
23const LINE_GREEN_BG: Color = Color::Rgb(0, 35, 0);
24const DIM: Style = Style::new().fg(Color::DarkGray);
25const HUNK_HEADER: Style = Style::new().fg(Color::Cyan);
26
27/// Width of the gutter: 4-digit line number + space + sigil + space = 7.
28/// Used by NoSelect to know how many leading columns to skip on copy.
29pub const GUTTER_WIDTH: u16 = 7;
30
31// ── Public API ────────────────────────────────────────────────
32
33/// Render a [`DiffPreview`] as native ratatui `Line`s.
34///
35/// Each diff line's gutter (line numbers + ±) occupies [`GUTTER_WIDTH`]
36/// columns. The caller can use this to implement NoSelect on copy.
37pub fn render_lines(preview: &DiffPreview) -> Vec<Line<'static>> {
38    match preview {
39        DiffPreview::UnifiedDiff(diff) => render_unified_diff(diff),
40        DiffPreview::WriteNew(w) => render_write_new(w),
41        DiffPreview::DeleteFile(d) => render_delete_file(d),
42        DiffPreview::DeleteDir(d) => render_delete_dir(d),
43        DiffPreview::FileNotYetExists => {
44            vec![Line::styled("(file does not exist yet)", DIM)]
45        }
46        DiffPreview::PathNotFound => {
47            vec![Line::styled("(path does not exist)", DIM)]
48        }
49    }
50}
51
52// ── Unified diff renderer ─────────────────────────────────────
53
54fn render_unified_diff(diff: &UnifiedDiffPreview) -> Vec<Line<'static>> {
55    let ext = std::path::Path::new(&diff.path)
56        .extension()
57        .and_then(|e| e.to_str())
58        .unwrap_or("");
59
60    // Pre-highlight both files for cross-hunk syntax context
61    let old_highlights = highlight::pre_highlight(&diff.old_content, ext);
62    let new_highlights = highlight::pre_highlight(&diff.new_content, ext);
63
64    let mut lines = Vec::new();
65
66    // File header
67    lines.push(Line::styled(format!("╭─── {} ───╮", diff.path), DIM));
68
69    for (i, hunk) in diff.hunks.iter().enumerate() {
70        // Hunk separator (between hunks, not before the first)
71        if i > 0 {
72            lines.push(Line::styled("  ⋯", DIM));
73        }
74
75        // Hunk header: @@ -old_start,old_count +new_start,new_count @@
76        lines.push(Line::styled(
77            format!(
78                "@@ -{},{} +{},{} @@",
79                hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
80            ),
81            HUNK_HEADER,
82        ));
83
84        // Hunk lines
85        for diff_line in &hunk.lines {
86            let rendered = render_diff_line(diff_line, &old_highlights, &new_highlights);
87            lines.push(rendered);
88        }
89    }
90
91    // Close frame
92    lines.push(Line::styled(format!("╰─── {} ───╯", diff.path), DIM));
93
94    if diff.truncated {
95        lines.push(Line::styled("... diff truncated (file too large)", DIM));
96    }
97
98    lines
99}
100
101/// Render a single diff line with gutter + syntax-highlighted content.
102///
103/// Uses pre-computed highlights from the full file for correct cross-hunk
104/// syntax context (multiline strings, comments, etc.).
105fn render_diff_line(
106    line: &DiffLine,
107    old_highlights: &[Vec<Span<'static>>],
108    new_highlights: &[Vec<Span<'static>>],
109) -> Line<'static> {
110    let (sigil, sigil_color, bg_color, highlights, line_num) = match line.tag {
111        DiffTag::Context => {
112            let num = line.old_line.unwrap_or(0);
113            (' ', Color::DarkGray, None, old_highlights, num)
114        }
115        DiffTag::Delete => {
116            let num = line.old_line.unwrap_or(0);
117            ('-', Color::Red, Some(LINE_RED_BG), old_highlights, num)
118        }
119        DiffTag::Insert => {
120            let num = line.new_line.unwrap_or(0);
121            ('+', Color::Green, Some(LINE_GREEN_BG), new_highlights, num)
122        }
123    };
124
125    let mut spans = Vec::new();
126
127    // Gutter: line number + sigil (GUTTER_WIDTH chars total)
128    let gutter_style = Style::default().fg(sigil_color).add_modifier(Modifier::DIM);
129    spans.push(Span::styled(
130        format!("{:>4} {} ", line_num, sigil),
131        gutter_style,
132    ));
133
134    // Content: use pre-highlighted spans if available, with background tint
135    let idx = line_num.saturating_sub(1); // 0-based index
136    if idx < highlights.len() {
137        for hl_span in &highlights[idx] {
138            let mut style = hl_span.style;
139            if let Some(bg) = bg_color {
140                style = style.bg(bg);
141            }
142            spans.push(Span::styled(hl_span.content.clone(), style));
143        }
144    } else {
145        // Fallback: no highlighting available
146        let style = match bg_color {
147            Some(bg) => Style::default().bg(bg),
148            None => Style::default(),
149        };
150        spans.push(Span::styled(line.content.clone(), style));
151    }
152
153    Line::from(spans)
154}
155
156// ── Write new file ────────────────────────────────────────────
157
158fn render_write_new(w: &WriteNewPreview) -> Vec<Line<'static>> {
159    let ext = std::path::Path::new(&w.path)
160        .extension()
161        .and_then(|e| e.to_str())
162        .unwrap_or("");
163
164    let mut lines = vec![Line::styled(
165        format!(
166            "╭─── {} (new file: {} lines, {} bytes) ───╮",
167            w.path, w.line_count, w.byte_count
168        ),
169        DIM,
170    )];
171
172    let mut hl = crate::highlight::CodeHighlighter::new(ext);
173    for (i, content) in w.first_lines.iter().enumerate() {
174        let mut spans = vec![Span::styled(
175            format!("{:>4} + ", i + 1),
176            Style::default()
177                .fg(Color::Green)
178                .add_modifier(Modifier::DIM),
179        )];
180        let highlighted = hl.highlight_spans(content);
181        for mut s in highlighted {
182            s.style = s.style.bg(LINE_GREEN_BG);
183            spans.push(s);
184        }
185        lines.push(Line::from(spans));
186    }
187
188    if w.truncated {
189        lines.push(Line::styled(
190            format!("... +{} more lines", w.line_count - w.first_lines.len()),
191            DIM,
192        ));
193    }
194
195    lines.push(Line::styled(format!("╰─── {} ───╯", w.path), DIM));
196
197    lines
198}
199
200// ── Delete ────────────────────────────────────────────────────
201
202fn render_delete_file(d: &DeleteFilePreview) -> Vec<Line<'static>> {
203    vec![Line::styled(
204        format!("Removing {} lines ({} bytes)", d.line_count, d.byte_count),
205        Style::default().bg(LINE_RED_BG),
206    )]
207}
208
209fn render_delete_dir(d: &DeleteDirPreview) -> Vec<Line<'static>> {
210    if d.recursive {
211        vec![Line::styled(
212            "Removing directory and all contents",
213            Style::default().bg(LINE_RED_BG),
214        )]
215    } else {
216        vec![Line::styled(
217            "Removing empty directory",
218            Style::default().bg(LINE_RED_BG),
219        )]
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use koda_core::preview::*;
227
228    #[test]
229    fn test_unified_diff_has_hunk_headers() {
230        let preview = DiffPreview::UnifiedDiff(UnifiedDiffPreview {
231            path: "test.rs".into(),
232            old_content: "fn main() {\n    println!(\"hello\");\n}\n".into(),
233            new_content: "fn main() {\n    println!(\"world\");\n}\n".into(),
234            hunks: vec![DiffHunk {
235                old_start: 1,
236                old_count: 3,
237                new_start: 1,
238                new_count: 3,
239                lines: vec![
240                    DiffLine {
241                        tag: DiffTag::Context,
242                        content: "fn main() {".into(),
243                        old_line: Some(1),
244                        new_line: Some(1),
245                    },
246                    DiffLine {
247                        tag: DiffTag::Delete,
248                        content: "    println!(\"hello\");".into(),
249                        old_line: Some(2),
250                        new_line: None,
251                    },
252                    DiffLine {
253                        tag: DiffTag::Insert,
254                        content: "    println!(\"world\");".into(),
255                        old_line: None,
256                        new_line: Some(2),
257                    },
258                    DiffLine {
259                        tag: DiffTag::Context,
260                        content: "}".into(),
261                        old_line: Some(3),
262                        new_line: Some(3),
263                    },
264                ],
265            }],
266            truncated: false,
267        });
268
269        let lines = render_lines(&preview);
270        let text: Vec<String> = lines
271            .iter()
272            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
273            .collect();
274
275        // Should have file header
276        assert!(text[0].contains("test.rs"), "header: {}", text[0]);
277        // Should have hunk header
278        assert!(
279            text.iter().any(|t| t.contains("@@")),
280            "should have hunk header"
281        );
282        // Should have line numbers with sigils
283        assert!(
284            text.iter().any(|t| t.contains(" - ")),
285            "should have delete marker"
286        );
287        assert!(
288            text.iter().any(|t| t.contains(" + ")),
289            "should have insert marker"
290        );
291    }
292
293    #[test]
294    fn test_write_new_rendering() {
295        let preview = DiffPreview::WriteNew(WriteNewPreview {
296            path: "new.rs".into(),
297            line_count: 10,
298            byte_count: 200,
299            first_lines: vec!["fn main() {}".into()],
300            truncated: true,
301        });
302        let lines = render_lines(&preview);
303        let text: Vec<String> = lines
304            .iter()
305            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
306            .collect();
307        assert!(text[0].contains("new.rs"));
308        assert!(text.iter().any(|t| t.contains("more lines")));
309    }
310
311    #[test]
312    fn test_hunk_separator_between_hunks() {
313        let preview = DiffPreview::UnifiedDiff(UnifiedDiffPreview {
314            path: "test.rs".into(),
315            old_content: String::new(),
316            new_content: String::new(),
317            hunks: vec![
318                DiffHunk {
319                    old_start: 1,
320                    old_count: 1,
321                    new_start: 1,
322                    new_count: 1,
323                    lines: vec![DiffLine {
324                        tag: DiffTag::Context,
325                        content: "a".into(),
326                        old_line: Some(1),
327                        new_line: Some(1),
328                    }],
329                },
330                DiffHunk {
331                    old_start: 50,
332                    old_count: 1,
333                    new_start: 50,
334                    new_count: 1,
335                    lines: vec![DiffLine {
336                        tag: DiffTag::Context,
337                        content: "b".into(),
338                        old_line: Some(50),
339                        new_line: Some(50),
340                    }],
341                },
342            ],
343            truncated: false,
344        });
345        let lines = render_lines(&preview);
346        let text: Vec<String> = lines
347            .iter()
348            .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
349            .collect();
350        assert!(
351            text.iter().any(|t| t.contains('⋯')),
352            "should have hunk separator"
353        );
354    }
355}