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    }
232}
233
234fn render_controls(frame: &mut Frame<'_>, area: Rect, preview: &DiffPreviewState) {
235    let lines = control_lines(preview);
236
237    frame.render_widget(
238        Paragraph::new(lines).block(
239            Block::default()
240                .borders(Borders::TOP)
241                .border_style(Style::default().fg(Color::DarkGray)),
242        ),
243        area,
244    );
245}
246
247fn control_lines(preview: &DiffPreviewState) -> Vec<Line<'static>> {
248    match preview.mode {
249        DiffPreviewMode::EditApproval => {
250            let trust = match preview.trust_mode {
251                TrustMode::Once => "Once",
252                TrustMode::Session => "Session",
253                TrustMode::Always => "Always",
254                TrustMode::AutoTrust => "Auto",
255            };
256
257            vec![
258                Line::from(vec![
259                    Span::styled(
260                        "Enter",
261                        Style::default()
262                            .fg(Color::Green)
263                            .add_modifier(Modifier::BOLD),
264                    ),
265                    Span::raw(" Apply  "),
266                    Span::styled(
267                        "Esc",
268                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
269                    ),
270                    Span::raw(" Reject  "),
271                    Span::styled("Tab", Style::default().fg(Color::Yellow)),
272                    Span::raw("/"),
273                    Span::styled("S-Tab", Style::default().fg(Color::Yellow)),
274                    Span::raw(" Nav"),
275                ]),
276                Line::from(vec![
277                    Span::styled("1", Style::default().fg(Color::Cyan)),
278                    Span::raw("-Once "),
279                    Span::styled("2", Style::default().fg(Color::Cyan)),
280                    Span::raw("-Sess "),
281                    Span::styled("3", Style::default().fg(Color::Cyan)),
282                    Span::raw("-Always "),
283                    Span::styled("4", Style::default().fg(Color::Cyan)),
284                    Span::raw("-Auto "),
285                    Span::styled(
286                        format!("[{}]", trust),
287                        Style::default()
288                            .fg(Color::DarkGray)
289                            .add_modifier(Modifier::BOLD),
290                    ),
291                ]),
292            ]
293        }
294        DiffPreviewMode::FileConflict => vec![
295            Line::from(vec![
296                Span::styled(
297                    "Enter",
298                    Style::default()
299                        .fg(Color::Green)
300                        .add_modifier(Modifier::BOLD),
301                ),
302                Span::raw(" Proceed  "),
303                Span::styled("r", Style::default().fg(Color::Cyan)),
304                Span::raw(" Reload  "),
305                Span::styled(
306                    "Esc",
307                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
308                ),
309                Span::raw(" Abort"),
310            ]),
311            Line::from(vec![
312                Span::styled("Tab", Style::default().fg(Color::Yellow)),
313                Span::raw("/"),
314                Span::styled("S-Tab", Style::default().fg(Color::Yellow)),
315                Span::raw(" Nav"),
316            ]),
317        ],
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::{apply_diff_bg_if_missing, control_lines, header_action_label};
324    use crate::core_tui::app::types::{DiffPreviewMode, DiffPreviewState};
325    use ratatui::style::{Color, Style};
326
327    #[test]
328    fn apply_diff_bg_if_missing_adds_bg_when_none() {
329        let styled = apply_diff_bg_if_missing(Style::default(), Some(Color::Green));
330        assert_eq!(styled.bg, Some(Color::Green));
331    }
332
333    #[test]
334    fn apply_diff_bg_if_missing_preserves_existing_bg() {
335        let base = Style::default().bg(Color::Blue);
336        let styled = apply_diff_bg_if_missing(base, Some(Color::Green));
337        assert_eq!(styled.bg, Some(Color::Blue));
338    }
339
340    #[test]
341    fn conflict_controls_show_proceed_reload_abort_copy() {
342        let preview = DiffPreviewState::new_with_mode(
343            "src/main.rs".to_string(),
344            "before".to_string(),
345            "after".to_string(),
346            Vec::new(),
347            DiffPreviewMode::FileConflict,
348        );
349
350        let lines = control_lines(&preview);
351        let first_line: String = lines[0]
352            .spans
353            .iter()
354            .map(|span| span.content.clone().into_owned())
355            .collect();
356
357        assert!(first_line.contains("Proceed"));
358        assert!(first_line.contains("Reload"));
359        assert!(first_line.contains("Abort"));
360    }
361
362    #[test]
363    fn conflict_header_uses_conflict_label() {
364        assert_eq!(
365            header_action_label(DiffPreviewMode::FileConflict),
366            "← Conflict "
367        );
368        assert_eq!(
369            header_action_label(DiffPreviewMode::EditApproval),
370            "← Edit "
371        );
372    }
373}