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