notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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 }