use diffy::Hunk; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line as RtLine; use ratatui::text::Span as RtSpan; use ratatui::widgets::Paragraph; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; use crate::exec_command::relativize_to_home; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use codex_core::git_info::get_git_repo_root; use codex_core::protocol::FileChange; const SPACES_AFTER_LINE_NUMBER: usize = 6; // Internal representation for diff line rendering enum DiffLineType { Insert, Delete, Context, } pub struct DiffSummary { changes: HashMap, cwd: PathBuf, } impl DiffSummary { pub fn new(changes: HashMap, cwd: PathBuf) -> Self { Self { changes, cwd } } } impl Renderable for FileChange { fn render(&self, area: Rect, buf: &mut Buffer) { let mut lines = vec![]; render_change(self, &mut lines, area.width as usize); Paragraph::new(lines).render(area, buf); } fn desired_height(&self, width: u16) -> u16 { let mut lines = vec![]; render_change(self, &mut lines, width as usize); lines.len() as u16 } } impl From for Box { fn from(val: DiffSummary) -> Self { let mut rows: Vec> = vec![]; for (i, row) in collect_rows(&val.changes).into_iter().enumerate() { if i > 0 { rows.push(Box::new(RtLine::from(""))); } let mut path = RtLine::from(display_path_for(&row.path, &val.cwd)); path.push_span(" "); path.extend(render_line_count_summary(row.added, row.removed)); rows.push(Box::new(path)); rows.push(Box::new(row.change)); } Box::new(ColumnRenderable::new(rows)) } } pub(crate) fn create_diff_summary( changes: &HashMap, cwd: &Path, wrap_cols: usize, ) -> Vec> { let rows = collect_rows(changes); render_changes_block(rows, wrap_cols, cwd) } // Shared row for per-file presentation #[derive(Clone)] struct Row { #[allow(dead_code)] path: PathBuf, move_path: Option, added: usize, removed: usize, change: FileChange, } fn collect_rows(changes: &HashMap) -> Vec { let mut rows: Vec = Vec::new(); for (path, change) in changes.iter() { let (added, removed) = match change { FileChange::Add { content } => (content.lines().count(), 0), FileChange::Delete { content } => (0, content.lines().count()), FileChange::Update { unified_diff, .. } => calculate_add_remove_from_diff(unified_diff), }; let move_path = match change { FileChange::Update { move_path: Some(new), .. } => Some(new.clone()), _ => None, }; rows.push(Row { path: path.clone(), move_path, added, removed, change: change.clone(), }); } rows.sort_by_key(|r| r.path.clone()); rows } fn render_line_count_summary(added: usize, removed: usize) -> Vec> { let mut spans = Vec::new(); spans.push("(".into()); spans.push(format!("+{added}").green()); spans.push(" ".into()); spans.push(format!("-{removed}").red()); spans.push(")".into()); spans } fn render_changes_block(rows: Vec, wrap_cols: usize, cwd: &Path) -> Vec> { let mut out: Vec> = Vec::new(); let render_path = |row: &Row| -> Vec> { let mut spans = Vec::new(); spans.push(display_path_for(&row.path, cwd).into()); if let Some(move_path) = &row.move_path { spans.push(format!(" → {}", display_path_for(move_path, cwd)).into()); } spans }; // Header let total_added: usize = rows.iter().map(|r| r.added).sum(); let total_removed: usize = rows.iter().map(|r| r.removed).sum(); let file_count = rows.len(); let noun = if file_count == 1 { "file" } else { "files" }; let mut header_spans: Vec> = vec!["• ".into()]; if let [row] = &rows[..] { let verb = match &row.change { FileChange::Add { .. } => "Added", FileChange::Delete { .. } => "Deleted", _ => "Edited", }; header_spans.push(verb.bold()); header_spans.push(" ".into()); header_spans.extend(render_path(row)); header_spans.push(" ".into()); header_spans.extend(render_line_count_summary(row.added, row.removed)); } else { header_spans.push("Edited".bold()); header_spans.push(format!(" {file_count} {noun} ").into()); header_spans.extend(render_line_count_summary(total_added, total_removed)); } out.push(RtLine::from(header_spans)); for (idx, r) in rows.into_iter().enumerate() { // Insert a blank separator between file chunks (except before the first) if idx > 0 { out.push("".into()); } // File header line (skip when single-file header already shows the name) let skip_file_header = file_count == 1; if !skip_file_header { let mut header: Vec> = Vec::new(); header.push(" └ ".dim()); header.extend(render_path(&r)); header.push(" ".into()); header.extend(render_line_count_summary(r.added, r.removed)); out.push(RtLine::from(header)); } render_change(&r.change, &mut out, wrap_cols); } out } fn render_change(change: &FileChange, out: &mut Vec>, width: usize) { match change { FileChange::Add { content } => { for (i, raw) in content.lines().enumerate() { out.extend(push_wrapped_diff_line( i + 1, DiffLineType::Insert, raw, width, )); } } FileChange::Delete { content } => { for (i, raw) in content.lines().enumerate() { out.extend(push_wrapped_diff_line( i + 1, DiffLineType::Delete, raw, width, )); } } FileChange::Update { unified_diff, .. } => { if let Ok(patch) = diffy::Patch::from_str(unified_diff) { let mut is_first_hunk = true; for h in patch.hunks() { if !is_first_hunk { out.push(RtLine::from(vec![" ".into(), "⋮".dim()])); } is_first_hunk = false; let mut old_ln = h.old_range().start(); let mut new_ln = h.new_range().start(); for l in h.lines() { match l { diffy::Line::Insert(text) => { let s = text.trim_end_matches('\n'); out.extend(push_wrapped_diff_line( new_ln, DiffLineType::Insert, s, width, )); new_ln += 1; } diffy::Line::Delete(text) => { let s = text.trim_end_matches('\n'); out.extend(push_wrapped_diff_line( old_ln, DiffLineType::Delete, s, width, )); old_ln += 1; } diffy::Line::Context(text) => { let s = text.trim_end_matches('\n'); out.extend(push_wrapped_diff_line( new_ln, DiffLineType::Context, s, width, )); old_ln += 1; new_ln += 1; } } } } } } } } pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, _ => false, }; let chosen = if path_in_same_repo { pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf()) } else { relativize_to_home(path).unwrap_or_else(|| path.to_path_buf()) }; chosen.display().to_string() } fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) { if let Ok(patch) = diffy::Patch::from_str(diff) { patch .hunks() .iter() .flat_map(Hunk::lines) .fold((0, 0), |(a, d), l| match l { diffy::Line::Insert(_) => (a + 1, d), diffy::Line::Delete(_) => (a, d + 1), diffy::Line::Context(_) => (a, d), }) } else { // For unparsable diffs, return 0 for both counts. (0, 0) } } fn push_wrapped_diff_line( line_number: usize, kind: DiffLineType, text: &str, width: usize, ) -> Vec> { let indent = " "; let ln_str = line_number.to_string(); let mut remaining_text: &str = text; // Reserve a fixed number of spaces after the line number so that content starts // at a consistent column. Content includes a 1-character diff sign prefix // ("+"/"-" for inserts/deletes, or a space for context lines) so alignment // stays consistent across all diff lines. let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len()); let prefix_cols = indent.len() + ln_str.len() + gap_after_ln; let mut first = true; let (sign_char, line_style) = match kind { DiffLineType::Insert => ('+', style_add()), DiffLineType::Delete => ('-', style_del()), DiffLineType::Context => (' ', style_context()), }; let mut lines: Vec> = Vec::new(); loop { // Fit the content for the current terminal row: // compute how many columns are available after the prefix, then split // at a UTF-8 character boundary so this row's chunk fits exactly. let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1); let split_at_byte_index = remaining_text .char_indices() .nth(available_content_cols) .map(|(i, _)| i) .unwrap_or_else(|| remaining_text.len()); let (chunk, rest) = remaining_text.split_at(split_at_byte_index); remaining_text = rest; if first { // Build gutter (indent + line number + spacing) as a dimmed span let gutter = format!("{indent}{ln_str}{}", " ".repeat(gap_after_ln)); // Content with a sign ('+'/'-'/' ') styled per diff kind let content = format!("{sign_char}{chunk}"); lines.push(RtLine::from(vec![ RtSpan::styled(gutter, style_gutter()), RtSpan::styled(content, line_style), ])); first = false; } else { // Continuation lines keep a space for the sign column so content aligns let gutter = format!("{indent}{} ", " ".repeat(ln_str.len() + gap_after_ln)); lines.push(RtLine::from(vec![ RtSpan::styled(gutter, style_gutter()), RtSpan::styled(chunk.to_string(), line_style), ])); } if remaining_text.is_empty() { break; } } lines } fn style_gutter() -> Style { Style::default().add_modifier(Modifier::DIM) } fn style_context() -> Style { Style::default() } fn style_add() -> Style { Style::default().fg(Color::Green) } fn style_del() -> Style { Style::default().fg(Color::Red) } #[cfg(test)] mod tests { use super::*; use insta::assert_snapshot; use ratatui::Terminal; use ratatui::backend::TestBackend; use ratatui::text::Text; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; fn diff_summary_for_tests(changes: &HashMap) -> Vec> { create_diff_summary(changes, &PathBuf::from("/"), 80) } fn snapshot_lines(name: &str, lines: Vec>, width: u16, height: u16) { let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); terminal .draw(|f| { Paragraph::new(Text::from(lines)) .wrap(Wrap { trim: false }) .render_ref(f.area(), f.buffer_mut()) }) .expect("draw"); assert_snapshot!(name, terminal.backend()); } fn snapshot_lines_text(name: &str, lines: &[RtLine<'static>]) { // Convert Lines to plain text rows and trim trailing spaces so it's // easier to validate indentation visually in snapshots. let text = lines .iter() .map(|l| { l.spans .iter() .map(|s| s.content.as_ref()) .collect::() }) .map(|s| s.trim_end().to_string()) .collect::>() .join("\n"); assert_snapshot!(name, text); } #[test] fn ui_snapshot_wrap_behavior_insert() { // Narrow width to force wrapping within our diff line rendering let long_line = "this is a very long line that should wrap across multiple terminal columns and continue"; // Call the wrapping function directly so we can precisely control the width let lines = push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80); // Render into a small terminal to capture the visual layout snapshot_lines("wrap_behavior_insert", lines, 90, 8); } #[test] fn ui_snapshot_apply_update_block() { let mut changes: HashMap = HashMap::new(); let original = "line one\nline two\nline three\n"; let modified = "line one\nline two changed\nline three\n"; let patch = diffy::create_patch(original, modified).to_string(); changes.insert( PathBuf::from("example.txt"), FileChange::Update { unified_diff: patch, move_path: None, }, ); for name in ["apply_update_block", "apply_update_block_manual"] { let lines = diff_summary_for_tests(&changes); snapshot_lines(name, lines, 80, 12); } } #[test] fn ui_snapshot_apply_update_with_rename_block() { let mut changes: HashMap = HashMap::new(); let original = "A\nB\nC\n"; let modified = "A\nB changed\nC\n"; let patch = diffy::create_patch(original, modified).to_string(); changes.insert( PathBuf::from("old_name.rs"), FileChange::Update { unified_diff: patch, move_path: Some(PathBuf::from("new_name.rs")), }, ); let lines = diff_summary_for_tests(&changes); snapshot_lines("apply_update_with_rename_block", lines, 80, 12); } #[test] fn ui_snapshot_apply_multiple_files_block() { // Two files: one update and one add, to exercise combined header and per-file rows let mut changes: HashMap = HashMap::new(); // File a.txt: single-line replacement (one delete, one insert) let patch_a = diffy::create_patch("one\n", "one changed\n").to_string(); changes.insert( PathBuf::from("a.txt"), FileChange::Update { unified_diff: patch_a, move_path: None, }, ); // File b.txt: newly added with one line changes.insert( PathBuf::from("b.txt"), FileChange::Add { content: "new\n".to_string(), }, ); let lines = diff_summary_for_tests(&changes); snapshot_lines("apply_multiple_files_block", lines, 80, 14); } #[test] fn ui_snapshot_apply_add_block() { let mut changes: HashMap = HashMap::new(); changes.insert( PathBuf::from("new_file.txt"), FileChange::Add { content: "alpha\nbeta\n".to_string(), }, ); let lines = diff_summary_for_tests(&changes); snapshot_lines("apply_add_block", lines, 80, 10); } #[test] fn ui_snapshot_apply_delete_block() { // Write a temporary file so the delete renderer can read original content let tmp_path = PathBuf::from("tmp_delete_example.txt"); std::fs::write(&tmp_path, "first\nsecond\nthird\n").expect("write tmp file"); let mut changes: HashMap = HashMap::new(); changes.insert( tmp_path.clone(), FileChange::Delete { content: "first\nsecond\nthird\n".to_string(), }, ); let lines = diff_summary_for_tests(&changes); // Cleanup best-effort; rendering has already read the file let _ = std::fs::remove_file(&tmp_path); snapshot_lines("apply_delete_block", lines, 80, 12); } #[test] fn ui_snapshot_apply_update_block_wraps_long_lines() { // Create a patch with a long modified line to force wrapping let original = "line 1\nshort\nline 3\n"; let modified = "line 1\nshort this_is_a_very_long_modified_line_that_should_wrap_across_multiple_terminal_columns_and_continue_even_further_beyond_eighty_columns_to_force_multiple_wraps\nline 3\n"; let patch = diffy::create_patch(original, modified).to_string(); let mut changes: HashMap = HashMap::new(); changes.insert( PathBuf::from("long_example.txt"), FileChange::Update { unified_diff: patch, move_path: None, }, ); let lines = create_diff_summary(&changes, &PathBuf::from("/"), 72); // Render with backend width wider than wrap width to avoid Paragraph auto-wrap. snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12); } #[test] fn ui_snapshot_apply_update_block_wraps_long_lines_text() { // This mirrors the desired layout example: sign only on first inserted line, // subsequent wrapped pieces start aligned under the line number gutter. let original = "1\n2\n3\n4\n"; let modified = "1\nadded long line which wraps and_if_there_is_a_long_token_it_will_be_broken\n3\n4 context line which also wraps across\n"; let patch = diffy::create_patch(original, modified).to_string(); let mut changes: HashMap = HashMap::new(); changes.insert( PathBuf::from("wrap_demo.txt"), FileChange::Update { unified_diff: patch, move_path: None, }, ); let mut lines = create_diff_summary(&changes, &PathBuf::from("/"), 28); // Drop the combined header for this text-only snapshot if !lines.is_empty() { lines.remove(0); } snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines); } #[test] fn ui_snapshot_apply_update_block_relativizes_path() { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); let abs_old = cwd.join("abs_old.rs"); let abs_new = cwd.join("abs_new.rs"); let original = "X\nY\n"; let modified = "X changed\nY\n"; let patch = diffy::create_patch(original, modified).to_string(); let mut changes: HashMap = HashMap::new(); changes.insert( abs_old, FileChange::Update { unified_diff: patch, move_path: Some(abs_new), }, ); let lines = create_diff_summary(&changes, &cwd, 80); snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10); } }