Skip to main content

vtcode_tui/core_tui/app/session/
diff_preview.rs

1//! Diff preview rendering for file edit approval
2//!
3//! Renders a syntax-highlighted diff preview with permission controls.
4
5use ratatui::{
6    Frame,
7    layout::{Constraint, Layout, Rect},
8    style::{Color, Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, Paragraph},
11};
12
13use super::Session;
14use crate::core_tui::app::types::{DiffPreviewMode, DiffPreviewState, TrustMode};
15use crate::core_tui::style::{ratatui_color_from_ansi, ratatui_style_from_ansi};
16use crate::ui::markdown::highlight_line_for_diff;
17use crate::utils::diff::{DiffBundle, DiffLineKind, DiffOptions, compute_diff_with_theme};
18use crate::utils::diff_styles::{
19    DiffColorPalette, DiffLineType, content_background, current_diff_render_style_context,
20    style_gutter, style_line_bg, style_sign,
21};
22
23pub fn render_diff_preview(session: &Session, frame: &mut Frame<'_>, area: Rect) {
24    let Some(preview) = session.diff_preview_state() else {
25        return;
26    };
27
28    let palette = DiffColorPalette::default();
29    let diff_bundle = compute_diff_with_theme(
30        &preview.before,
31        &preview.after,
32        DiffOptions {
33            context_lines: 3,
34            old_label: None,
35            new_label: None,
36            missing_newline_hint: false,
37        },
38    );
39    let (additions, deletions) = count_diff_changes(&diff_bundle);
40
41    let chunks = Layout::vertical([
42        Constraint::Length(2),
43        Constraint::Min(5),
44        Constraint::Length(4),
45    ])
46    .split(area);
47
48    render_file_header(frame, chunks[0], preview, &palette, additions, deletions);
49    render_diff_content(frame, chunks[1], preview, &diff_bundle);
50    render_controls(frame, chunks[2], preview);
51}
52
53fn render_file_header(
54    frame: &mut Frame<'_>,
55    area: Rect,
56    preview: &DiffPreviewState,
57    palette: &DiffColorPalette,
58    additions: usize,
59    deletions: usize,
60) {
61    let header_style = Style::default().fg(ratatui_color_from_ansi(palette.header_fg));
62    let header = Line::from(vec![
63        Span::styled(header_action_label(preview.mode), header_style),
64        Span::styled(&preview.file_path, header_style),
65        Span::styled(" (", header_style),
66        Span::styled(
67            format!("+{}", additions),
68            Style::default().fg(ratatui_color_from_ansi(palette.added_fg)),
69        ),
70        Span::styled(" ", header_style),
71        Span::styled(
72            format!("-{}", deletions),
73            Style::default().fg(ratatui_color_from_ansi(palette.removed_fg)),
74        ),
75        Span::styled(")", header_style),
76    ]);
77    frame.render_widget(Paragraph::new(header), area);
78}
79
80fn detect_language(file_path: &str) -> Option<&'static str> {
81    let ext = file_path.rsplit('.').next()?;
82    match ext.to_lowercase().as_str() {
83        "rs" => Some("rust"),
84        "py" => Some("python"),
85        "js" => Some("javascript"),
86        "ts" | "tsx" => Some("typescript"),
87        "go" => Some("go"),
88        "java" => Some("java"),
89        "sh" | "bash" => Some("bash"),
90        "swift" => Some("swift"),
91        "c" | "h" => Some("c"),
92        "cpp" | "cc" | "cxx" | "hpp" => Some("cpp"),
93        "json" => Some("json"),
94        "yaml" | "yml" => Some("yaml"),
95        "toml" => Some("toml"),
96        "md" => Some("markdown"),
97        "html" | "htm" => Some("html"),
98        "css" | "scss" => Some("css"),
99        _ => None,
100    }
101}
102
103fn highlight_line_with_bg(
104    line: &str,
105    language: Option<&str>,
106    bg: Option<Color>,
107) -> Vec<Span<'static>> {
108    let text = line.trim_end_matches('\n');
109    if let Some(segments) = highlight_line_for_diff(text, language) {
110        segments
111            .into_iter()
112            .map(|(anstyle, t)| {
113                let style = apply_diff_bg_if_missing(ratatui_style_from_ansi(anstyle), bg);
114                Span::styled(t, style)
115            })
116            .collect()
117    } else {
118        let style = apply_diff_bg_if_missing(Style::default(), bg);
119        vec![Span::styled(text.to_string(), style)]
120    }
121}
122
123fn apply_diff_bg_if_missing(mut style: Style, bg: Option<Color>) -> Style {
124    if style.bg.is_none()
125        && let Some(bg_color) = bg
126    {
127        style = style.bg(bg_color);
128    }
129    style
130}
131
132fn count_diff_changes(diff_bundle: &DiffBundle) -> (usize, usize) {
133    let mut additions = 0usize;
134    let mut deletions = 0usize;
135
136    for hunk in &diff_bundle.hunks {
137        for line in &hunk.lines {
138            match line.kind {
139                DiffLineKind::Addition => additions += 1,
140                DiffLineKind::Deletion => deletions += 1,
141                DiffLineKind::Context => {}
142            }
143        }
144    }
145
146    (additions, deletions)
147}
148
149fn render_diff_content(
150    frame: &mut Frame<'_>,
151    area: Rect,
152    preview: &DiffPreviewState,
153    diff_bundle: &DiffBundle,
154) {
155    let language = detect_language(&preview.file_path);
156    let style_context = current_diff_render_style_context();
157
158    let mut lines: Vec<Line> = Vec::new();
159    let max_display = area.height.saturating_sub(1) as usize;
160
161    for hunk in &diff_bundle.hunks {
162        if lines.len() >= max_display {
163            break;
164        }
165
166        lines.push(Line::from(Span::styled(
167            format!("@@ -{} +{} @@", hunk.old_start, hunk.new_start),
168            Style::default().fg(Color::Cyan),
169        )));
170
171        for diff_line in &hunk.lines {
172            if lines.len() >= max_display {
173                break;
174            }
175
176            let line_num = match diff_line.kind {
177                DiffLineKind::Context => diff_line.new_line.unwrap_or(0),
178                DiffLineKind::Addition => diff_line.new_line.unwrap_or(0),
179                DiffLineKind::Deletion => diff_line.old_line.unwrap_or(0),
180            };
181            let line_num_str = format!("{:>4} ", line_num);
182            let text = diff_line.text.trim_end_matches('\n');
183
184            let line_type = match diff_line.kind {
185                DiffLineKind::Context => DiffLineType::Context,
186                DiffLineKind::Addition => DiffLineType::Insert,
187                DiffLineKind::Deletion => DiffLineType::Delete,
188            };
189
190            let gutter_style = style_gutter(line_type);
191            let sign_style = style_sign(line_type);
192            let line_bg = style_line_bg(line_type, style_context);
193            let content_bg = content_background(line_type, style_context);
194
195            let prefix = match line_type {
196                DiffLineType::Insert => "+",
197                DiffLineType::Delete => "-",
198                DiffLineType::Context => " ",
199            };
200
201            let mut spans = vec![
202                Span::styled(prefix.to_string(), sign_style),
203                Span::styled(line_num_str, gutter_style),
204            ];
205
206            // For changed lines with syntax highlighting, apply bg tint
207            let highlighted = highlight_line_with_bg(text, language, content_bg);
208            spans.extend(highlighted);
209
210            lines.push(Line::from(spans).style(line_bg));
211        }
212    }
213
214    if lines.is_empty() {
215        lines.push(Line::from(Span::styled(
216            "(no changes)",
217            Style::default().fg(Color::DarkGray),
218        )));
219    }
220
221    frame.render_widget(
222        Paragraph::new(lines).block(Block::default().borders(Borders::NONE)),
223        area,
224    );
225}
226
227fn header_action_label(mode: DiffPreviewMode) -> &'static str {
228    match mode {
229        DiffPreviewMode::EditApproval => "← Edit ",
230        DiffPreviewMode::FileConflict => "← Conflict ",
231        DiffPreviewMode::ReadonlyReview => "← Review ",
232    }
233}
234
235fn render_controls(frame: &mut Frame<'_>, area: Rect, preview: &DiffPreviewState) {
236    let lines = control_lines(preview);
237
238    frame.render_widget(
239        Paragraph::new(lines).block(
240            Block::default()
241                .borders(Borders::TOP)
242                .border_style(Style::default().fg(Color::DarkGray)),
243        ),
244        area,
245    );
246}
247
248fn control_lines(preview: &DiffPreviewState) -> Vec<Line<'static>> {
249    match preview.mode {
250        DiffPreviewMode::EditApproval => {
251            let trust = match preview.trust_mode {
252                TrustMode::Once => "Once",
253                TrustMode::Session => "Session",
254                TrustMode::Always => "Always",
255                TrustMode::AutoTrust => "Auto",
256            };
257
258            vec![
259                Line::from(vec![
260                    Span::styled(
261                        "Enter",
262                        Style::default()
263                            .fg(Color::Green)
264                            .add_modifier(Modifier::BOLD),
265                    ),
266                    Span::raw(" Apply  "),
267                    Span::styled(
268                        "Esc",
269                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
270                    ),
271                    Span::raw(" Reject  "),
272                    Span::styled("Tab", Style::default().fg(Color::Yellow)),
273                    Span::raw("/"),
274                    Span::styled("S-Tab", Style::default().fg(Color::Yellow)),
275                    Span::raw(" Nav"),
276                ]),
277                Line::from(vec![
278                    Span::styled("1", Style::default().fg(Color::Cyan)),
279                    Span::raw("-Once "),
280                    Span::styled("2", Style::default().fg(Color::Cyan)),
281                    Span::raw("-Sess "),
282                    Span::styled("3", Style::default().fg(Color::Cyan)),
283                    Span::raw("-Always "),
284                    Span::styled("4", Style::default().fg(Color::Cyan)),
285                    Span::raw("-Auto "),
286                    Span::styled(
287                        format!("[{}]", trust),
288                        Style::default()
289                            .fg(Color::DarkGray)
290                            .add_modifier(Modifier::BOLD),
291                    ),
292                ]),
293            ]
294        }
295        DiffPreviewMode::FileConflict => vec![
296            Line::from(vec![
297                Span::styled(
298                    "Enter",
299                    Style::default()
300                        .fg(Color::Green)
301                        .add_modifier(Modifier::BOLD),
302                ),
303                Span::raw(" Proceed  "),
304                Span::styled("r", Style::default().fg(Color::Cyan)),
305                Span::raw(" Reload  "),
306                Span::styled(
307                    "Esc",
308                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
309                ),
310                Span::raw(" Abort"),
311            ]),
312            Line::from(vec![
313                Span::styled("Tab", Style::default().fg(Color::Yellow)),
314                Span::raw("/"),
315                Span::styled("S-Tab", Style::default().fg(Color::Yellow)),
316                Span::raw(" Nav"),
317            ]),
318        ],
319        DiffPreviewMode::ReadonlyReview => vec![
320            Line::from(vec![
321                Span::styled(
322                    "Enter",
323                    Style::default()
324                        .fg(Color::Green)
325                        .add_modifier(Modifier::BOLD),
326                ),
327                Span::raw(" Back  "),
328                Span::styled(
329                    "Esc",
330                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
331                ),
332                Span::raw(" Back"),
333            ]),
334            Line::from(vec![
335                Span::styled("Tab", Style::default().fg(Color::Yellow)),
336                Span::raw("/"),
337                Span::styled("S-Tab", Style::default().fg(Color::Yellow)),
338                Span::raw(" Nav"),
339            ]),
340        ],
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::{apply_diff_bg_if_missing, control_lines, header_action_label};
347    use crate::core_tui::app::types::{DiffPreviewMode, DiffPreviewState};
348    use ratatui::style::{Color, Style};
349
350    #[test]
351    fn apply_diff_bg_if_missing_adds_bg_when_none() {
352        let styled = apply_diff_bg_if_missing(Style::default(), Some(Color::Green));
353        assert_eq!(styled.bg, Some(Color::Green));
354    }
355
356    #[test]
357    fn apply_diff_bg_if_missing_preserves_existing_bg() {
358        let base = Style::default().bg(Color::Blue);
359        let styled = apply_diff_bg_if_missing(base, Some(Color::Green));
360        assert_eq!(styled.bg, Some(Color::Blue));
361    }
362
363    #[test]
364    fn conflict_controls_show_proceed_reload_abort_copy() {
365        let preview = DiffPreviewState::new_with_mode(
366            "src/main.rs".to_string(),
367            "before".to_string(),
368            "after".to_string(),
369            Vec::new(),
370            DiffPreviewMode::FileConflict,
371        );
372
373        let lines = control_lines(&preview);
374        let first_line: String = lines[0]
375            .spans
376            .iter()
377            .map(|span| span.content.clone().into_owned())
378            .collect();
379
380        assert!(first_line.contains("Proceed"));
381        assert!(first_line.contains("Reload"));
382        assert!(first_line.contains("Abort"));
383    }
384
385    #[test]
386    fn readonly_review_controls_show_back_navigation() {
387        let preview = DiffPreviewState::new_with_mode(
388            "src/main.rs".to_string(),
389            "before".to_string(),
390            "after".to_string(),
391            Vec::new(),
392            DiffPreviewMode::ReadonlyReview,
393        );
394
395        let lines = control_lines(&preview);
396        let first_line: String = lines[0]
397            .spans
398            .iter()
399            .map(|span| span.content.clone().into_owned())
400            .collect();
401
402        assert!(first_line.contains("Back"));
403        assert!(!first_line.contains("Proceed"));
404        assert!(!first_line.contains("Reload"));
405    }
406
407    #[test]
408    fn conflict_header_uses_conflict_label() {
409        assert_eq!(
410            header_action_label(DiffPreviewMode::FileConflict),
411            "← Conflict "
412        );
413        assert_eq!(
414            header_action_label(DiffPreviewMode::EditApproval),
415            "← Edit "
416        );
417        assert_eq!(
418            header_action_label(DiffPreviewMode::ReadonlyReview),
419            "← Review "
420        );
421    }
422}