wisp/components/git_diff/
patch_renderer.rs1use 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}