1use 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}