//! Diff rendering helpers for TUI previews. use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; use crate::palette; const LINE_NUMBER_WIDTH: usize = 4; pub fn render_diff(diff: &str, width: u16) -> Vec> { let mut lines = Vec::new(); let mut old_line: Option = None; let mut new_line: Option = None; for raw in diff.lines() { if raw.starts_with("diff --git") || raw.starts_with("index ") { lines.extend(render_header_line(raw, width)); continue; } if raw.starts_with("--- ") || raw.starts_with("+++ ") { lines.extend(render_header_line(raw, width)); continue; } if raw.starts_with("@@") { if let Some((old_start, new_start)) = parse_hunk_header(raw) { old_line = Some(old_start); new_line = Some(new_start); } lines.extend(render_hunk_header(raw, width)); continue; } if raw.starts_with('+') && !raw.starts_with("+++") { let content = raw.trim_start_matches('+'); lines.extend(render_diff_line( content, width, old_line, new_line, Style::default().fg(palette::STATUS_SUCCESS), )); if let Some(line) = new_line.as_mut() { *line = line.saturating_add(1); } continue; } if raw.starts_with('-') && !raw.starts_with("---") { let content = raw.trim_start_matches('-'); lines.extend(render_diff_line( content, width, old_line, new_line, Style::default().fg(palette::STATUS_ERROR), )); if let Some(line) = old_line.as_mut() { *line = line.saturating_add(1); } continue; } if raw.starts_with(' ') { let content = raw.trim_start_matches(' '); lines.extend(render_diff_line( content, width, old_line, new_line, Style::default().fg(palette::TEXT_PRIMARY), )); if let Some(line) = old_line.as_mut() { *line = line.saturating_add(1); } if let Some(line) = new_line.as_mut() { *line = line.saturating_add(1); } continue; } lines.extend(render_header_line(raw, width)); } lines } fn parse_hunk_header(line: &str) -> Option<(usize, usize)> { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() < 3 { return None; } let old = parts[1].trim_start_matches('-'); let new = parts[2].trim_start_matches('+'); let old_start = old.split(',').next()?.parse::().ok()?; let new_start = new.split(',').next()?.parse::().ok()?; Some((old_start, new_start)) } fn render_header_line(line: &str, width: u16) -> Vec> { let style = Style::default() .fg(palette::DEEPSEEK_SKY) .add_modifier(Modifier::BOLD); wrap_with_style(line, style, width) } fn render_hunk_header(line: &str, width: u16) -> Vec> { let style = Style::default().fg(palette::DEEPSEEK_BLUE); wrap_with_style(line, style, width) } fn render_diff_line( content: &str, width: u16, old_line: Option, new_line: Option, style: Style, ) -> Vec> { let prefix = format_line_numbers(old_line, new_line); let prefix_width = prefix.width(); let available = width.saturating_sub(prefix_width as u16).max(1) as usize; let wrapped = wrap_text(content, available); let mut out = Vec::new(); for (idx, chunk) in wrapped.into_iter().enumerate() { if idx == 0 { out.push(Line::from(vec![ Span::styled(prefix.clone(), Style::default().fg(palette::TEXT_MUTED)), Span::styled(chunk, style), ])); } else { out.push(Line::from(vec![ Span::raw(" ".repeat(prefix_width)), Span::styled(chunk, style), ])); } } if out.is_empty() { out.push(Line::from(vec![Span::styled( prefix, Style::default().fg(palette::TEXT_MUTED), )])); } out } fn format_line_numbers(old_line: Option, new_line: Option) -> String { let old = old_line .map(|value| { format!( "{value:>LINE_NUMBER_WIDTH$}", LINE_NUMBER_WIDTH = LINE_NUMBER_WIDTH ) }) .unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH)); let new = new_line .map(|value| { format!( "{value:>LINE_NUMBER_WIDTH$}", LINE_NUMBER_WIDTH = LINE_NUMBER_WIDTH ) }) .unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH)); format!("{old} {new} | ") } fn wrap_with_style(text: &str, style: Style, width: u16) -> Vec> { let mut out = Vec::new(); for part in wrap_text(text, width.max(1) as usize) { out.push(Line::from(Span::styled(part, style))); } if out.is_empty() { out.push(Line::from(Span::styled("", style))); } out } fn wrap_text(text: &str, width: usize) -> Vec { if width == 0 { return vec![text.to_string()]; } let mut lines = Vec::new(); let mut current = String::new(); let mut current_width = 0; for word in text.split_whitespace() { let word_width = word.width(); let additional = if current.is_empty() { word_width } else { word_width + 1 }; if current_width + additional > width && !current.is_empty() { lines.push(current); current = word.to_string(); current_width = word_width; } else { if !current.is_empty() { current.push(' '); current_width += 1; } current.push_str(word); current_width += word_width; } } if current.is_empty() { lines.push(String::new()); } else { lines.push(current); } lines }