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::Insets; use crate::render::line_utils::prefix_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; use codex_core::git_info::get_git_repo_root; use codex_core::protocol::FileChange; // 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(RtLine::from(""))); rows.push(Box::new(InsetRenderable::new( row.change, Insets::tlbr(0, 2, 0, 0), ))); } Box::new(ColumnRenderable::with(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!["• ".dim()]; 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)); } let mut lines = vec![]; render_change(&r.change, &mut lines, wrap_cols - 4); out.extend(prefix_lines(lines, " ".into(), " ".into())); } out } fn render_change(change: &FileChange, out: &mut Vec>, width: usize) { match change { FileChange::Add { content } => { let line_number_width = line_number_width(content.lines().count()); for (i, raw) in content.lines().enumerate() { out.extend(push_wrapped_diff_line( i + 1, DiffLineType::Insert, raw, width, line_number_width, )); } } FileChange::Delete { content } => { let line_number_width = line_number_width(content.lines().count()); for (i, raw) in content.lines().enumerate() { out.extend(push_wrapped_diff_line( i + 1, DiffLineType::Delete, raw, width, line_number_width, )); } } FileChange::Update { unified_diff, .. } => { if let Ok(patch) = diffy::Patch::from_str(unified_diff) { let mut max_line_number = 0; for h in patch.hunks() { 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(_) => { max_line_number = max_line_number.max(new_ln); new_ln += 1; } diffy::Line::Delete(_) => { max_line_number = max_line_number.max(old_ln); old_ln += 1; } diffy::Line::Context(_) => { max_line_number = max_line_number.max(new_ln); old_ln += 1; new_ln += 1; } } } } let line_number_width = line_number_width(max_line_number); let mut is_first_hunk = true; for h in patch.hunks() { if !is_first_hunk { let spacer = format!("{:width$} ", "", width = line_number_width.max(1)); let spacer_span = RtSpan::styled(spacer, style_gutter()); out.push(RtLine::from(vec![spacer_span, "⋮".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, line_number_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, line_number_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, line_number_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) .map(|p| PathBuf::from_iter([Path::new("~"), p.as_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, line_number_width: usize, ) -> Vec> { let ln_str = line_number.to_string(); let mut remaining_text: &str = text; // Reserve a fixed number of spaces (equal to the widest line number plus a // trailing spacer) so the sign column stays aligned across the diff block. let gutter_width = line_number_width.max(1); let prefix_cols = gutter_width + 1; 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 (right-aligned line number plus spacer) as a dimmed span let gutter = format!("{ln_str:>gutter_width$} "); // 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!("{:gutter_width$} ", ""); 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 line_number_width(max_line_number: usize) -> usize { if max_line_number == 0 { 1 } else { max_line_number.to_string().len() } } 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, line_number_width(1)); // 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, }, ); let lines = diff_summary_for_tests(&changes); snapshot_lines("apply_update_block", 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 lines = create_diff_summary(&changes, &PathBuf::from("/"), 28); snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines); } #[test] fn ui_snapshot_apply_update_block_line_numbers_three_digits_text() { let original = (1..=110).map(|i| format!("line {i}\n")).collect::(); let modified = (1..=110) .map(|i| { if i == 100 { format!("line {i} changed\n") } else { format!("line {i}\n") } }) .collect::(); let patch = diffy::create_patch(&original, &modified).to_string(); let mut changes: HashMap = HashMap::new(); changes.insert( PathBuf::from("hundreds.txt"), FileChange::Update { unified_diff: patch, move_path: None, }, ); let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80); snapshot_lines_text("apply_update_block_line_numbers_three_digits_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); } }