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