diff.rs (8166B)
1 use super::super::file_update::{DiffLine, DiffTag, FileUpdate, FileUpdateType}; 2 use super::markdown_ui::{tokenize_code, SandCodeTheme}; 3 use egui::text::LayoutJob; 4 use egui::{Color32, FontId, RichText, TextFormat, Ui}; 5 6 /// Colors for diff rendering 7 const DELETE_COLOR: Color32 = Color32::from_rgb(200, 60, 60); 8 const INSERT_COLOR: Color32 = Color32::from_rgb(60, 180, 60); 9 const LINE_NUMBER_COLOR: Color32 = Color32::from_rgb(128, 128, 128); 10 const EXPAND_LINES_PER_CLICK: usize = 3; 11 12 /// Soft background tints for syntax-highlighted diff lines. 13 /// Uses premultiplied alpha: rgb(200,60,60) @ alpha=40 and rgb(60,180,60) @ alpha=40. 14 const DELETE_BG: Color32 = Color32::from_rgba_premultiplied(31, 9, 9, 40); 15 const INSERT_BG: Color32 = Color32::from_rgba_premultiplied(9, 28, 9, 40); 16 17 /// Render a file update diff view. 18 /// 19 /// When `is_local` is true and the update is an Edit, expand-context 20 /// buttons are shown at the top and bottom of the diff. 21 pub fn file_update_ui(update: &FileUpdate, is_local: bool, ui: &mut Ui) { 22 let can_expand = is_local && matches!(update.update_type, FileUpdateType::Edit { .. }); 23 24 // egui temp state for how many extra lines above/below 25 let expand_id = ui.id().with("diff_expand").with(&update.file_path); 26 let (extra_above, extra_below): (usize, usize) = if can_expand { 27 ui.data(|d| d.get_temp(expand_id).unwrap_or((0, 0))) 28 } else { 29 (0, 0) 30 }; 31 32 // Try to compute expanded context from the file on disk 33 let expanded = if can_expand { 34 update.expanded_context(extra_above, extra_below) 35 } else { 36 None 37 }; 38 39 egui::Frame::new() 40 .fill(ui.visuals().extreme_bg_color) 41 .inner_margin(8.0) 42 .corner_radius(4.0) 43 .show(ui, |ui| { 44 egui::ScrollArea::horizontal().show(ui, |ui| { 45 if let Some(ctx) = &expanded { 46 // "Expand above" button 47 if ctx.has_more_above && expand_button(ui, true) { 48 ui.data_mut(|d| { 49 d.insert_temp( 50 expand_id, 51 (extra_above + EXPAND_LINES_PER_CLICK, extra_below), 52 ); 53 }); 54 } 55 56 // Build combined lines: above + core diff + below 57 let combined: Vec<&DiffLine> = ctx 58 .above 59 .iter() 60 .chain(update.diff_lines().iter()) 61 .chain(ctx.below.iter()) 62 .collect(); 63 64 render_diff_lines( 65 &combined, 66 &update.update_type, 67 ctx.start_line, 68 &update.file_path, 69 ui, 70 ); 71 72 // "Expand below" button 73 if ctx.has_more_below && expand_button(ui, false) { 74 ui.data_mut(|d| { 75 d.insert_temp( 76 expand_id, 77 (extra_above, extra_below + EXPAND_LINES_PER_CLICK), 78 ); 79 }); 80 } 81 } else { 82 // No expansion available: render as before (line numbers from 1) 83 let refs: Vec<&DiffLine> = update.diff_lines().iter().collect(); 84 render_diff_lines(&refs, &update.update_type, 1, &update.file_path, ui); 85 } 86 }); 87 }); 88 } 89 90 /// Render a clickable expand-context button. Returns true if clicked. 91 fn expand_button(ui: &mut Ui, is_above: bool) -> bool { 92 let text = if is_above { 93 " \u{25B2} Show more context above" 94 } else { 95 " \u{25BC} Show more context below" 96 }; 97 ui.add( 98 egui::Label::new( 99 RichText::new(text) 100 .monospace() 101 .size(11.0) 102 .color(LINE_NUMBER_COLOR), 103 ) 104 .sense(egui::Sense::click()), 105 ) 106 .on_hover_cursor(egui::CursorIcon::PointingHand) 107 .clicked() 108 } 109 110 /// Render the diff lines with syntax highlighting. 111 /// 112 /// `start_line` is the 1-based file line number of the first displayed line. 113 fn render_diff_lines( 114 lines: &[&DiffLine], 115 update_type: &FileUpdateType, 116 start_line: usize, 117 file_path: &str, 118 ui: &mut Ui, 119 ) { 120 let mut old_line = start_line; 121 let mut new_line = start_line; 122 123 let font_id = FontId::new(12.0, egui::FontFamily::Monospace); 124 let theme = SandCodeTheme::from_visuals(ui.visuals()); 125 let lang = file_extension(file_path).unwrap_or("text"); 126 127 for diff_line in lines { 128 ui.horizontal(|ui| { 129 // Line number gutter 130 let (old_num, new_num) = match diff_line.tag { 131 DiffTag::Equal => { 132 let result = (Some(old_line), Some(new_line)); 133 old_line += 1; 134 new_line += 1; 135 result 136 } 137 DiffTag::Delete => { 138 let result = (Some(old_line), None); 139 old_line += 1; 140 result 141 } 142 DiffTag::Insert => { 143 let result = (None, Some(new_line)); 144 new_line += 1; 145 result 146 } 147 }; 148 149 // Render line numbers (only for edits, not writes) 150 if matches!(update_type, FileUpdateType::Edit { .. }) { 151 let old_str = old_num 152 .map(|n| format!("{:4}", n)) 153 .unwrap_or_else(|| " ".to_string()); 154 let new_str = new_num 155 .map(|n| format!("{:4}", n)) 156 .unwrap_or_else(|| " ".to_string()); 157 158 ui.label( 159 RichText::new(format!("{} {}", old_str, new_str)) 160 .monospace() 161 .size(11.0) 162 .color(LINE_NUMBER_COLOR), 163 ); 164 } 165 166 // Prefix character and its strong diff color 167 let (prefix, prefix_color) = match diff_line.tag { 168 DiffTag::Equal => (" ", ui.visuals().text_color()), 169 DiffTag::Delete => ("-", DELETE_COLOR), 170 DiffTag::Insert => ("+", INSERT_COLOR), 171 }; 172 173 // Remove trailing newline for display 174 let content = diff_line.content.trim_end_matches('\n'); 175 176 // Background tint signals diff status 177 let line_bg = match diff_line.tag { 178 DiffTag::Equal => Color32::TRANSPARENT, 179 DiffTag::Delete => DELETE_BG, 180 DiffTag::Insert => INSERT_BG, 181 }; 182 183 let mut job = LayoutJob::default(); 184 185 // Prefix with diff color 186 job.append( 187 &format!("{} ", prefix), 188 0.0, 189 TextFormat { 190 font_id: font_id.clone(), 191 color: prefix_color, 192 background: line_bg, 193 ..Default::default() 194 }, 195 ); 196 197 // Syntax-highlighted content 198 for (token, text) in tokenize_code(content, lang) { 199 let mut fmt = theme.format(token, &font_id); 200 fmt.background = line_bg; 201 job.append(text, 0.0, fmt); 202 } 203 204 ui.label(job); 205 }); 206 } 207 } 208 209 /// Extract file extension from a path. 210 fn file_extension(path: &str) -> Option<&str> { 211 std::path::Path::new(path) 212 .extension() 213 .and_then(|ext| ext.to_str()) 214 } 215 216 /// Render the file path header (call within a horizontal layout) 217 pub fn file_path_header(update: &FileUpdate, ui: &mut Ui) { 218 let type_label = match &update.update_type { 219 FileUpdateType::Edit { .. } => "Edit", 220 FileUpdateType::Write { .. } => "Write", 221 FileUpdateType::UnifiedDiff { .. } => "Diff", 222 }; 223 224 ui.label(RichText::new(type_label).strong()); 225 ui.label(RichText::new(&update.file_path).monospace()); 226 }