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};
12use vtcode_commons::diff_paths::language_hint_from_path;
13use vtcode_commons::diff_preview::{DiffDisplayKind, count_diff_changes, display_lines_from_hunks};
14
15use super::Session;
16use crate::core_tui::app::types::{DiffPreviewMode, DiffPreviewState, TrustMode};
17use crate::core_tui::style::{ratatui_color_from_ansi, ratatui_style_from_ansi};
18use crate::ui::markdown::render_diff_content_segments;
19use crate::utils::diff::{DiffBundle, DiffOptions, compute_diff_with_theme};
20use crate::utils::diff_styles::{
21    DiffColorPalette, DiffLineType, current_diff_render_style_context, style_content, style_gutter,
22    style_line_bg, style_sign,
23};
24
25pub fn render_diff_preview(session: &Session, frame: &mut Frame<'_>, area: Rect) {
26    let Some(preview) = session.diff_preview_state() else {
27        return;
28    };
29
30    let palette = DiffColorPalette::default();
31    let diff_bundle = compute_diff_with_theme(
32        &preview.before,
33        &preview.after,
34        DiffOptions {
35            context_lines: 3,
36            old_label: None,
37            new_label: None,
38            missing_newline_hint: false,
39        },
40    );
41    let counts = count_diff_changes(&diff_bundle.hunks);
42
43    let chunks = Layout::vertical([
44        Constraint::Length(2),
45        Constraint::Min(5),
46        Constraint::Length(4),
47    ])
48    .split(area);
49
50    render_file_header(
51        frame,
52        chunks[0],
53        preview,
54        &palette,
55        counts.additions,
56        counts.deletions,
57    );
58    render_diff_content(frame, chunks[1], preview, &diff_bundle);
59    render_controls(frame, chunks[2], preview);
60}
61
62fn render_file_header(
63    frame: &mut Frame<'_>,
64    area: Rect,
65    preview: &DiffPreviewState,
66    palette: &DiffColorPalette,
67    additions: usize,
68    deletions: usize,
69) {
70    let header_style = Style::default().fg(ratatui_color_from_ansi(palette.header_fg));
71    let header = Line::from(vec![
72        Span::styled(header_action_label(preview.mode), header_style),
73        Span::styled(&preview.file_path, header_style),
74        Span::styled(" (", header_style),
75        Span::styled(
76            format!("+{}", additions),
77            Style::default().fg(ratatui_color_from_ansi(palette.added_fg)),
78        ),
79        Span::styled(" ", header_style),
80        Span::styled(
81            format!("-{}", deletions),
82            Style::default().fg(ratatui_color_from_ansi(palette.removed_fg)),
83        ),
84        Span::styled(")", header_style),
85    ]);
86    frame.render_widget(Paragraph::new(header), area);
87}
88
89fn render_diff_content(
90    frame: &mut Frame<'_>,
91    area: Rect,
92    preview: &DiffPreviewState,
93    diff_bundle: &DiffBundle,
94) {
95    let language = language_hint_from_path(&preview.file_path);
96    let style_context = current_diff_render_style_context();
97
98    let mut lines: Vec<Line> = Vec::new();
99    let max_display = area.height.saturating_sub(1) as usize;
100    let display_lines = display_lines_from_hunks(&diff_bundle.hunks);
101
102    for display_line in display_lines {
103        if lines.len() >= max_display {
104            break;
105        }
106
107        match display_line.kind {
108            DiffDisplayKind::HunkHeader => {
109                lines.push(Line::from(Span::styled(
110                    display_line.text,
111                    Style::default().fg(Color::Cyan),
112                )));
113            }
114            DiffDisplayKind::Metadata => {
115                lines.push(Line::from(Span::styled(
116                    display_line.text,
117                    Style::default().fg(Color::DarkGray),
118                )));
119            }
120            DiffDisplayKind::Context | DiffDisplayKind::Addition | DiffDisplayKind::Deletion => {
121                let line_num_str = format!("{:>4} ", display_line.line_number.unwrap_or(0));
122                let line_type = match display_line.kind {
123                    DiffDisplayKind::Context => DiffLineType::Context,
124                    DiffDisplayKind::Addition => DiffLineType::Insert,
125                    DiffDisplayKind::Deletion => DiffLineType::Delete,
126                    DiffDisplayKind::Metadata | DiffDisplayKind::HunkHeader => unreachable!(),
127                };
128
129                let gutter_style = style_gutter(line_type);
130                let sign_style = style_sign(line_type);
131                let line_bg = style_line_bg(line_type, style_context);
132                let content_style = style_content(line_type, style_context);
133
134                let prefix = match line_type {
135                    DiffLineType::Insert => "+",
136                    DiffLineType::Delete => "-",
137                    DiffLineType::Context => " ",
138                };
139
140                let mut spans = vec![
141                    Span::styled(prefix.to_string(), sign_style),
142                    Span::styled(line_num_str, gutter_style),
143                ];
144
145                for segment in render_diff_content_segments(
146                    &display_line.text,
147                    language.as_deref(),
148                    anstyle::Style::new(),
149                ) {
150                    let style = content_style.patch(ratatui_style_from_ansi(segment.style));
151                    spans.push(Span::styled(segment.text, style));
152                }
153
154                lines.push(Line::from(spans).style(line_bg));
155            }
156        }
157    }
158
159    if lines.is_empty() {
160        lines.push(Line::from(Span::styled(
161            "(no changes)",
162            Style::default().fg(Color::DarkGray),
163        )));
164    }
165
166    frame.render_widget(
167        Paragraph::new(lines).block(Block::default().borders(Borders::NONE)),
168        area,
169    );
170}
171
172fn header_action_label(mode: DiffPreviewMode) -> &'static str {
173    match mode {
174        DiffPreviewMode::EditApproval => "← Edit ",
175        DiffPreviewMode::FileConflict => "← Conflict ",
176        DiffPreviewMode::ReadonlyReview => "← Review ",
177    }
178}
179
180fn render_controls(frame: &mut Frame<'_>, area: Rect, preview: &DiffPreviewState) {
181    let lines = control_lines(preview);
182
183    frame.render_widget(
184        Paragraph::new(lines).block(
185            Block::default()
186                .borders(Borders::TOP)
187                .border_style(Style::default().fg(Color::DarkGray)),
188        ),
189        area,
190    );
191}
192
193fn control_lines(preview: &DiffPreviewState) -> Vec<Line<'static>> {
194    match preview.mode {
195        DiffPreviewMode::EditApproval => {
196            let trust = match preview.trust_mode {
197                TrustMode::Once => "Once",
198                TrustMode::Session => "Session",
199                TrustMode::Always => "Always",
200                TrustMode::AutoTrust => "Auto",
201            };
202
203            vec![
204                Line::from(vec![
205                    Span::styled(
206                        "Enter",
207                        Style::default()
208                            .fg(Color::Green)
209                            .add_modifier(Modifier::BOLD),
210                    ),
211                    Span::raw(" Apply  "),
212                    Span::styled(
213                        "Esc",
214                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
215                    ),
216                    Span::raw(" Reject  "),
217                    Span::styled("Tab", Style::default().fg(Color::Yellow)),
218                    Span::raw("/"),
219                    Span::styled("S-Tab", Style::default().fg(Color::Yellow)),
220                    Span::raw(" Nav"),
221                ]),
222                Line::from(vec![
223                    Span::styled("1", Style::default().fg(Color::Cyan)),
224                    Span::raw("-Once "),
225                    Span::styled("2", Style::default().fg(Color::Cyan)),
226                    Span::raw("-Sess "),
227                    Span::styled("3", Style::default().fg(Color::Cyan)),
228                    Span::raw("-Always "),
229                    Span::styled("4", Style::default().fg(Color::Cyan)),
230                    Span::raw("-Auto "),
231                    Span::styled(
232                        format!("[{}]", trust),
233                        Style::default()
234                            .fg(Color::DarkGray)
235                            .add_modifier(Modifier::BOLD),
236                    ),
237                ]),
238            ]
239        }
240        DiffPreviewMode::FileConflict => vec![
241            Line::from(vec![
242                Span::styled(
243                    "Enter",
244                    Style::default()
245                        .fg(Color::Green)
246                        .add_modifier(Modifier::BOLD),
247                ),
248                Span::raw(" Proceed  "),
249                Span::styled("r", Style::default().fg(Color::Cyan)),
250                Span::raw(" Reload  "),
251                Span::styled(
252                    "Esc",
253                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
254                ),
255                Span::raw(" Abort"),
256            ]),
257            Line::from(vec![
258                Span::styled("Tab", Style::default().fg(Color::Yellow)),
259                Span::raw("/"),
260                Span::styled("S-Tab", Style::default().fg(Color::Yellow)),
261                Span::raw(" Nav"),
262            ]),
263        ],
264        DiffPreviewMode::ReadonlyReview => vec![
265            Line::from(vec![
266                Span::styled(
267                    "Enter",
268                    Style::default()
269                        .fg(Color::Green)
270                        .add_modifier(Modifier::BOLD),
271                ),
272                Span::raw(" Back  "),
273                Span::styled(
274                    "Esc",
275                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
276                ),
277                Span::raw(" Back"),
278            ]),
279            Line::from(vec![
280                Span::styled("Tab", Style::default().fg(Color::Yellow)),
281                Span::raw("/"),
282                Span::styled("S-Tab", Style::default().fg(Color::Yellow)),
283                Span::raw(" Nav"),
284            ]),
285        ],
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::{control_lines, header_action_label};
292    use crate::core_tui::app::types::{DiffPreviewMode, DiffPreviewState};
293
294    #[test]
295    fn conflict_controls_show_proceed_reload_abort_copy() {
296        let preview = DiffPreviewState::new_with_mode(
297            "src/main.rs".to_string(),
298            "before".to_string(),
299            "after".to_string(),
300            Vec::new(),
301            DiffPreviewMode::FileConflict,
302        );
303
304        let lines = control_lines(&preview);
305        let first_line: String = lines[0]
306            .spans
307            .iter()
308            .map(|span| span.content.clone().into_owned())
309            .collect();
310
311        assert!(first_line.contains("Proceed"));
312        assert!(first_line.contains("Reload"));
313        assert!(first_line.contains("Abort"));
314    }
315
316    #[test]
317    fn readonly_review_controls_show_back_navigation() {
318        let preview = DiffPreviewState::new_with_mode(
319            "src/main.rs".to_string(),
320            "before".to_string(),
321            "after".to_string(),
322            Vec::new(),
323            DiffPreviewMode::ReadonlyReview,
324        );
325
326        let lines = control_lines(&preview);
327        let first_line: String = lines[0]
328            .spans
329            .iter()
330            .map(|span| span.content.clone().into_owned())
331            .collect();
332
333        assert!(first_line.contains("Back"));
334        assert!(!first_line.contains("Proceed"));
335        assert!(!first_line.contains("Reload"));
336    }
337
338    #[test]
339    fn conflict_header_uses_conflict_label() {
340        assert_eq!(
341            header_action_label(DiffPreviewMode::FileConflict),
342            "← Conflict "
343        );
344        assert_eq!(
345            header_action_label(DiffPreviewMode::EditApproval),
346            "← Edit "
347        );
348        assert_eq!(
349            header_action_label(DiffPreviewMode::ReadonlyReview),
350            "← Review "
351        );
352    }
353}