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