Skip to main content

wisp/components/git_diff/
patch_renderer.rs

1use crate::components::app::git_diff_mode::PatchLineRef;
2use crate::git_diff::{FileDiff, PatchLineKind};
3use std::collections::HashMap;
4use tui::{Line, ViewContext};
5
6pub struct RenderedPatch {
7    pub lines: Vec<Line>,
8    pub line_refs: Vec<Option<PatchLineRef>>,
9    pub line_ref_to_anchor_row_index: HashMap<PatchLineRef, usize>,
10    pub hunk_offsets: Vec<usize>,
11}
12
13impl RenderedPatch {
14    pub(crate) fn new(
15        lines: Vec<Line>,
16        line_refs: Vec<Option<PatchLineRef>>,
17        line_ref_to_anchor_row_index: HashMap<PatchLineRef, usize>,
18    ) -> Self {
19        let hunk_offsets = compute_hunk_offsets(&line_refs);
20        Self { lines, line_refs, line_ref_to_anchor_row_index, hunk_offsets }
21    }
22
23    pub fn from_file_diff(file: &FileDiff, width: usize, ctx: &ViewContext) -> RenderedPatch {
24        let theme = &ctx.theme;
25        let lang_hint = lang_hint_from_path(&file.path);
26        let mut patch_lines = Vec::new();
27        let mut patch_refs = Vec::new();
28        let mut anchor_insert_row_lookup = HashMap::new();
29        let max_line_no = file
30            .hunks
31            .iter()
32            .flat_map(|hunk| &hunk.lines)
33            .filter_map(|line| line.old_line_no.into_iter().chain(line.new_line_no).max())
34            .max()
35            .unwrap_or(0);
36
37        let gutter_width = get_n_digits(max_line_no);
38
39        for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
40            if hunk_idx > 0 {
41                patch_lines.push(Line::default());
42                patch_refs.push(None);
43            }
44
45            for (line_idx, patch_line) in hunk.lines.iter().enumerate() {
46                let mut line = Line::default();
47
48                match patch_line.kind {
49                    PatchLineKind::HunkHeader => {
50                        line.push_with_style(
51                            &patch_line.text,
52                            tui::Style::fg(theme.info()).bold().bg_color(theme.code_bg()),
53                        );
54                    }
55                    PatchLineKind::Context => {
56                        let old_str = format_line_no(patch_line.old_line_no, gutter_width);
57                        let new_str = format_line_no(patch_line.new_line_no, gutter_width);
58                        line.push_with_style(format!("{old_str} {new_str}   "), tui::Style::fg(theme.text_secondary()));
59                        append_syntax_spans(&mut line, &patch_line.text, lang_hint, None, ctx);
60                    }
61
62                    PatchLineKind::Added => {
63                        let old_str = " ".repeat(gutter_width);
64                        let new_str = format_line_no(patch_line.new_line_no, gutter_width);
65                        let bg = Some(theme.diff_added_bg());
66                        let style = tui::Style::fg(theme.diff_added_fg()).bg_color(theme.diff_added_bg());
67                        line.push_with_style(format!("{old_str} {new_str} + "), style);
68                        append_syntax_spans(&mut line, &patch_line.text, lang_hint, bg, ctx);
69                    }
70                    PatchLineKind::Removed => {
71                        let old_str = format_line_no(patch_line.old_line_no, gutter_width);
72                        let new_str = " ".repeat(gutter_width);
73                        let bg = Some(theme.diff_removed_bg());
74                        let style = tui::Style::fg(theme.diff_removed_fg()).bg_color(theme.diff_removed_bg());
75                        line.push_with_style(format!("{old_str} {new_str} - "), style);
76                        append_syntax_spans(&mut line, &patch_line.text, lang_hint, bg, ctx);
77                    }
78                    PatchLineKind::Meta => {
79                        line.push_with_style(&patch_line.text, tui::Style::fg(theme.text_secondary()).italic());
80                    }
81                }
82
83                let anchor = PatchLineRef { hunk_index: hunk_idx, line_index: line_idx };
84                let width_u16 = usize_to_u16_saturating(width);
85                let wrapped = tui::Frame::new(vec![line])
86                    .fit(width_u16, tui::FitOptions::wrap())
87                    .map_lines(|mut wrapped_line| {
88                        wrapped_line.extend_bg_to_width(width);
89                        wrapped_line
90                    })
91                    .into_lines();
92
93                anchor_insert_row_lookup.insert(anchor, patch_lines.len() + wrapped.len());
94                patch_refs.push(Some(anchor));
95                patch_refs.extend(std::iter::repeat_n(None, wrapped.len().saturating_sub(1)));
96                patch_lines.extend(wrapped);
97            }
98        }
99
100        Self::new(patch_lines, patch_refs, anchor_insert_row_lookup)
101    }
102}
103
104fn append_syntax_spans(
105    line: &mut Line,
106    text: &str,
107    lang_hint: &str,
108    bg_override: Option<tui::Color>,
109    ctx: &ViewContext,
110) {
111    let spans = ctx.highlighter().highlight(text, lang_hint, &ctx.theme);
112    if let Some(content) = spans.first() {
113        for span in content.spans() {
114            let mut span_style = span.style();
115            if let Some(bg) = bg_override {
116                span_style.bg = Some(bg);
117            }
118            line.push_span(tui::Span::with_style(span.text(), span_style));
119        }
120    } else {
121        line.push_text(text);
122    }
123}
124
125fn format_line_no(line_no: Option<usize>, width: usize) -> String {
126    match line_no {
127        Some(line_no) => format!("{line_no:>width$}"),
128        None => " ".repeat(width),
129    }
130}
131
132pub(crate) fn lang_hint_from_path(path: &str) -> &str {
133    path.rsplit('.').next().unwrap_or("")
134}
135
136pub(crate) fn usize_to_u16_saturating(value: usize) -> u16 {
137    u16::try_from(value).unwrap_or(u16::MAX)
138}
139
140fn compute_hunk_offsets(line_refs: &[Option<PatchLineRef>]) -> Vec<usize> {
141    let mut offsets = Vec::new();
142    let mut last_hunk: Option<usize> = None;
143
144    for (index, line_ref) in line_refs.iter().enumerate() {
145        if let Some(patch_line_ref) = line_ref
146            && last_hunk != Some(patch_line_ref.hunk_index)
147        {
148            offsets.push(index);
149            last_hunk = Some(patch_line_ref.hunk_index);
150        }
151    }
152
153    offsets
154}
155
156fn get_n_digits(mut n: usize) -> usize {
157    if n == 0 {
158        return 1;
159    }
160
161    let mut count = 0;
162    while n > 0 {
163        count += 1;
164        n /= 10;
165    }
166    count
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::git_diff::{FileDiff, FileStatus, Hunk, PatchLine};
173
174    fn make_file(lines: Vec<PatchLine>) -> FileDiff {
175        FileDiff {
176            old_path: Some("test.rs".to_string()),
177            path: "test.rs".to_string(),
178            status: FileStatus::Modified,
179            hunks: vec![Hunk {
180                header: "@@ -1,1 +1,1 @@".to_string(),
181                old_start: 1,
182                old_count: 1,
183                new_start: 1,
184                new_count: 1,
185                lines,
186            }],
187            binary: false,
188        }
189    }
190
191    #[test]
192    fn rendered_patch_contains_insert_row_lookup() {
193        let file = make_file(vec![
194            PatchLine {
195                kind: PatchLineKind::HunkHeader,
196                text: "@@ -1,1 +1,1 @@".to_string(),
197                old_line_no: None,
198                new_line_no: None,
199            },
200            PatchLine {
201                kind: PatchLineKind::Context,
202                text: "fn test()".to_string(),
203                old_line_no: Some(1),
204                new_line_no: Some(1),
205            },
206        ]);
207        let context = ViewContext::new((120, 24));
208        let result = RenderedPatch::from_file_diff(&file, 80, &context);
209
210        assert_eq!(result.line_ref_to_anchor_row_index.len(), 2);
211        assert_eq!(result.line_ref_to_anchor_row_index[&PatchLineRef { hunk_index: 0, line_index: 0 }], 1);
212    }
213
214    #[test]
215    fn rendered_patch_contains_hunk_offsets() {
216        let file = make_file(vec![
217            PatchLine {
218                kind: PatchLineKind::HunkHeader,
219                text: "@@ -1,1 +1,1 @@".to_string(),
220                old_line_no: None,
221                new_line_no: None,
222            },
223            PatchLine {
224                kind: PatchLineKind::Context,
225                text: "fn test()".to_string(),
226                old_line_no: Some(1),
227                new_line_no: Some(1),
228            },
229        ]);
230        let context = ViewContext::new((120, 24));
231        let result = RenderedPatch::from_file_diff(&file, 80, &context);
232
233        assert!(!result.hunk_offsets.is_empty(), "should have at least one hunk offset");
234        assert_eq!(result.hunk_offsets[0], 0, "first hunk should start at row 0");
235    }
236
237    #[test]
238    fn long_lines_soft_wrapped_to_right_width() {
239        let long_content = "x".repeat(200);
240        let file = make_file(vec![
241            PatchLine {
242                kind: PatchLineKind::HunkHeader,
243                text: "@@ -1,1 +1,1 @@".to_string(),
244                old_line_no: None,
245                new_line_no: None,
246            },
247            PatchLine { kind: PatchLineKind::Added, text: long_content, old_line_no: None, new_line_no: Some(1) },
248        ]);
249        let context = ViewContext::new((120, 24));
250        let right_width = 60;
251        let result = RenderedPatch::from_file_diff(&file, right_width, &context);
252
253        assert!(result.lines.len() > 2, "long line should wrap, got {} lines", result.lines.len());
254
255        for (index, line) in result.lines.iter().enumerate() {
256            let display_width = line.display_width();
257            assert!(
258                display_width <= right_width,
259                "line {index} width {display_width} exceeds right_width {right_width}"
260            );
261        }
262
263        assert!(result.line_refs[1].is_some(), "first wrapped line should have a ref");
264        for (index, line_ref) in result.line_refs.iter().enumerate().skip(2) {
265            assert!(line_ref.is_none(), "continuation line {index} should have None ref");
266        }
267    }
268
269    #[test]
270    fn digit_count_works() {
271        assert_eq!(get_n_digits(0), 1);
272        assert_eq!(get_n_digits(1), 1);
273        assert_eq!(get_n_digits(9), 1);
274        assert_eq!(get_n_digits(10), 2);
275        assert_eq!(get_n_digits(99), 2);
276        assert_eq!(get_n_digits(100), 3);
277        assert_eq!(get_n_digits(999), 3);
278    }
279
280    #[test]
281    fn lang_hint_extracts_extension() {
282        assert_eq!(lang_hint_from_path("src/main.rs"), "rs");
283        assert_eq!(lang_hint_from_path("foo.py"), "py");
284        assert_eq!(lang_hint_from_path("Makefile"), "Makefile");
285        assert_eq!(lang_hint_from_path("a/b/c.tsx"), "tsx");
286    }
287
288    #[test]
289    fn usize_to_u16_saturating_clamps_large_values() {
290        assert_eq!(usize_to_u16_saturating(123), 123);
291        assert_eq!(usize_to_u16_saturating(usize::from(u16::MAX)), u16::MAX);
292        assert_eq!(usize_to_u16_saturating(usize::from(u16::MAX) + 1), u16::MAX);
293    }
294}