Skip to main content

wisp/components/
patch_renderer.rs

1use crate::components::app::git_diff_mode::{PatchLineRef, QueuedComment};
2use crate::git_diff::{FileDiff, PatchLineKind};
3use std::collections::HashMap;
4use tui::{Color, Line, Span, Style, ViewContext, soft_wrap_line};
5
6pub(crate) fn build_comment_map(comments: &[QueuedComment]) -> HashMap<PatchLineRef, Vec<&QueuedComment>> {
7    let mut map: HashMap<PatchLineRef, Vec<&QueuedComment>> = HashMap::new();
8    for c in comments {
9        map.entry(c.patch_ref).or_default().push(c);
10    }
11    map
12}
13
14pub fn build_patch_lines(
15    file: &FileDiff,
16    right_width: usize,
17    context: &ViewContext,
18    comments: &[QueuedComment],
19) -> (Vec<Line>, Vec<Option<PatchLineRef>>) {
20    let theme = &context.theme;
21    let lang_hint = lang_hint_from_path(&file.path);
22    let mut patch_lines = Vec::new();
23    let mut patch_refs = Vec::new();
24
25    let comment_map = build_comment_map(comments);
26
27    let max_line_no = file
28        .hunks
29        .iter()
30        .flat_map(|h| &h.lines)
31        .filter_map(|l| l.old_line_no.into_iter().chain(l.new_line_no).max())
32        .max()
33        .unwrap_or(0);
34    let gutter_width = digit_count(max_line_no);
35
36    for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
37        if hunk_idx > 0 {
38            patch_lines.push(Line::default());
39            patch_refs.push(None);
40        }
41
42        for (line_idx, pl) in hunk.lines.iter().enumerate() {
43            let mut line = Line::default();
44
45            match pl.kind {
46                PatchLineKind::HunkHeader => {
47                    line.push_with_style(&pl.text, Style::fg(theme.info()).bold().bg_color(theme.code_bg()));
48                }
49                PatchLineKind::Context => {
50                    let old_str = format_line_no(pl.old_line_no, gutter_width);
51                    let new_str = format_line_no(pl.new_line_no, gutter_width);
52                    line.push_with_style(format!("{old_str} {new_str}   "), Style::fg(theme.text_secondary()));
53                    append_syntax_spans(&mut line, &pl.text, lang_hint, None, context);
54                }
55                PatchLineKind::Added => {
56                    let old_str = " ".repeat(gutter_width);
57                    let new_str = format_line_no(pl.new_line_no, gutter_width);
58                    let bg = Some(theme.diff_added_bg());
59                    let style = Style::fg(theme.diff_added_fg()).bg_color(theme.diff_added_bg());
60                    line.push_with_style(format!("{old_str} {new_str} + "), style);
61                    append_syntax_spans(&mut line, &pl.text, lang_hint, bg, context);
62                }
63                PatchLineKind::Removed => {
64                    let old_str = format_line_no(pl.old_line_no, gutter_width);
65                    let new_str = " ".repeat(gutter_width);
66                    let bg = Some(theme.diff_removed_bg());
67                    let style = Style::fg(theme.diff_removed_fg()).bg_color(theme.diff_removed_bg());
68                    line.push_with_style(format!("{old_str} {new_str} - "), style);
69                    append_syntax_spans(&mut line, &pl.text, lang_hint, bg, context);
70                }
71                PatchLineKind::Meta => {
72                    line.push_with_style(&pl.text, Style::fg(theme.text_secondary()).italic());
73                }
74            }
75
76            let anchor = PatchLineRef { hunk_index: hunk_idx, line_index: line_idx };
77
78            #[allow(clippy::cast_possible_truncation)]
79            let wrapped = soft_wrap_line(&line, right_width as u16);
80            for (i, mut wrapped_line) in wrapped.into_iter().enumerate() {
81                wrapped_line.extend_bg_to_width(right_width);
82                patch_lines.push(wrapped_line);
83                if i == 0 {
84                    patch_refs.push(Some(anchor));
85                } else {
86                    patch_refs.push(None);
87                }
88            }
89
90            if let Some(line_comments) = comment_map.get(&anchor) {
91                append_inline_comment_rows(&mut patch_lines, &mut patch_refs, line_comments, right_width, theme);
92            }
93        }
94    }
95
96    (patch_lines, patch_refs)
97}
98
99pub(crate) fn lang_hint_from_path(path: &str) -> &str {
100    path.rsplit('.').next().unwrap_or("")
101}
102
103pub(crate) fn append_syntax_spans(
104    line: &mut Line,
105    text: &str,
106    lang_hint: &str,
107    bg_override: Option<Color>,
108    context: &ViewContext,
109) {
110    let spans = context.highlighter().highlight(text, lang_hint, &context.theme);
111    if let Some(content) = spans.first() {
112        for span in content.spans() {
113            let mut span_style = span.style();
114            if let Some(bg) = bg_override {
115                span_style.bg = Some(bg);
116            }
117            line.push_span(Span::with_style(span.text(), span_style));
118        }
119    } else {
120        line.push_text(text);
121    }
122}
123
124pub(crate) fn append_inline_comment_rows(
125    patch_lines: &mut Vec<Line>,
126    patch_refs: &mut Vec<Option<PatchLineRef>>,
127    comments: &[&QueuedComment],
128    right_width: usize,
129    theme: &tui::Theme,
130) {
131    let indent = 2;
132    let box_left = "│ ";
133    let bg = theme.sidebar_bg();
134    let border_fg = theme.muted();
135    let text_fg = theme.text_primary();
136
137    let dashes = right_width.saturating_sub(indent + 1);
138
139    for comment in comments {
140        let inner_width = right_width.saturating_sub(indent + box_left.len() + 1);
141        let wrapped = wrap_text(&comment.comment, inner_width);
142
143        push_border_row(patch_lines, "┌", indent, dashes, right_width, border_fg, bg);
144        patch_refs.push(None);
145
146        for text_line in &wrapped {
147            let mut row = Line::default();
148            row.push_with_style(" ".repeat(indent), Style::default().bg_color(bg));
149            row.push_with_style(box_left, Style::fg(border_fg).bg_color(bg));
150            row.push_with_style(text_line.as_str(), Style::fg(text_fg).bg_color(bg));
151            row.extend_bg_to_width(right_width);
152            patch_lines.push(row);
153            patch_refs.push(None);
154        }
155
156        push_border_row(patch_lines, "└", indent, dashes, right_width, border_fg, bg);
157        patch_refs.push(None);
158    }
159}
160
161fn push_border_row(
162    lines: &mut Vec<Line>,
163    corner: &str,
164    indent: usize,
165    dashes: usize,
166    right_width: usize,
167    border_fg: Color,
168    bg: Color,
169) {
170    let mut row = Line::default();
171    row.push_with_style(" ".repeat(indent), Style::default().bg_color(bg));
172    row.push_with_style(corner, Style::fg(border_fg).bg_color(bg));
173    row.push_with_style("─".repeat(dashes), Style::fg(border_fg).bg_color(bg));
174    row.extend_bg_to_width(right_width);
175    lines.push(row);
176}
177
178fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
179    if max_width == 0 {
180        return vec![String::new()];
181    }
182    let mut lines = Vec::new();
183    let mut current = String::new();
184    let mut current_len = 0usize;
185
186    for word in text.split_whitespace() {
187        let word_len = word.chars().count();
188        if current_len == 0 {
189            current.push_str(word);
190            current_len = word_len;
191        } else if current_len + 1 + word_len <= max_width {
192            current.push(' ');
193            current.push_str(word);
194            current_len += 1 + word_len;
195        } else {
196            lines.push(std::mem::take(&mut current));
197            current.push_str(word);
198            current_len = word_len;
199        }
200    }
201    if !current.is_empty() || lines.is_empty() {
202        lines.push(current);
203    }
204    lines
205}
206
207pub(crate) fn format_line_no(line_no: Option<usize>, width: usize) -> String {
208    match line_no {
209        Some(n) => format!("{n:>width$}"),
210        None => " ".repeat(width),
211    }
212}
213
214pub(crate) fn digit_count(mut n: usize) -> usize {
215    if n == 0 {
216        return 1;
217    }
218    let mut count = 0;
219    while n > 0 {
220        count += 1;
221        n /= 10;
222    }
223    count
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::git_diff::{FileDiff, FileStatus, Hunk, PatchLine};
230    use tui::display_width_text;
231
232    fn make_file(lines: Vec<PatchLine>) -> FileDiff {
233        FileDiff {
234            old_path: Some("test.rs".to_string()),
235            path: "test.rs".to_string(),
236            status: FileStatus::Modified,
237            hunks: vec![Hunk {
238                header: "@@ -1,1 +1,1 @@".to_string(),
239                old_start: 1,
240                old_count: 1,
241                new_start: 1,
242                new_count: 1,
243                lines,
244            }],
245            binary: false,
246        }
247    }
248
249    #[test]
250    fn long_lines_soft_wrapped_to_right_width() {
251        let long_content = "x".repeat(200);
252        let file = make_file(vec![
253            PatchLine {
254                kind: PatchLineKind::HunkHeader,
255                text: "@@ -1,1 +1,1 @@".to_string(),
256                old_line_no: None,
257                new_line_no: None,
258            },
259            PatchLine { kind: PatchLineKind::Added, text: long_content, old_line_no: None, new_line_no: Some(1) },
260        ]);
261        let context = ViewContext::new((120, 24));
262        let right_width = 60;
263        let (lines, refs) = build_patch_lines(&file, right_width, &context, &[]);
264
265        // The long line should have wrapped into multiple visual lines
266        assert!(lines.len() > 2, "long line should wrap, got {} lines", lines.len());
267
268        // No visual line should exceed right_width
269        for (i, line) in lines.iter().enumerate() {
270            let w = line.display_width();
271            assert!(w <= right_width, "line {i} width {w} exceeds right_width {right_width}: {}", line.plain_text());
272        }
273
274        // First wrapped line gets the ref, continuations get None
275        assert!(refs[1].is_some(), "first wrapped line should have a ref");
276        for (i, r) in refs.iter().enumerate().skip(2) {
277            assert!(r.is_none(), "continuation line {i} should have None ref");
278        }
279    }
280
281    #[test]
282    fn short_lines_not_wrapped() {
283        let file = make_file(vec![
284            PatchLine {
285                kind: PatchLineKind::HunkHeader,
286                text: "@@ -1,1 +1,1 @@".to_string(),
287                old_line_no: None,
288                new_line_no: None,
289            },
290            PatchLine {
291                kind: PatchLineKind::Context,
292                text: "short".to_string(),
293                old_line_no: Some(1),
294                new_line_no: Some(1),
295            },
296        ]);
297        let context = ViewContext::new((120, 24));
298        let (lines, refs) = build_patch_lines(&file, 80, &context, &[]);
299
300        assert_eq!(lines.len(), 2, "short lines should not wrap");
301        assert!(refs[0].is_some());
302        assert!(refs[1].is_some());
303    }
304
305    #[test]
306    fn wrapped_lines_extend_bg_to_width() {
307        let long_content = "x".repeat(200);
308        let file = make_file(vec![PatchLine {
309            kind: PatchLineKind::Added,
310            text: long_content,
311            old_line_no: None,
312            new_line_no: Some(1),
313        }]);
314        let context = ViewContext::new((120, 24));
315        let right_width = 60;
316        let (lines, _) = build_patch_lines(&file, right_width, &context, &[]);
317
318        // All lines from the added line should have consistent width due to bg extension
319        for line in &lines {
320            let w = display_width_text(&line.plain_text());
321            assert_eq!(w, right_width, "line should be padded to right_width: {}", line.plain_text());
322        }
323    }
324
325    #[test]
326    fn digit_count_works() {
327        assert_eq!(digit_count(0), 1);
328        assert_eq!(digit_count(1), 1);
329        assert_eq!(digit_count(9), 1);
330        assert_eq!(digit_count(10), 2);
331        assert_eq!(digit_count(99), 2);
332        assert_eq!(digit_count(100), 3);
333        assert_eq!(digit_count(999), 3);
334    }
335
336    #[test]
337    fn lang_hint_extracts_extension() {
338        assert_eq!(lang_hint_from_path("src/main.rs"), "rs");
339        assert_eq!(lang_hint_from_path("foo.py"), "py");
340        assert_eq!(lang_hint_from_path("Makefile"), "Makefile");
341        assert_eq!(lang_hint_from_path("a/b/c.tsx"), "tsx");
342    }
343
344    #[test]
345    fn inline_comment_renders_below_target_line() {
346        let file = make_file(vec![
347            PatchLine {
348                kind: PatchLineKind::HunkHeader,
349                text: "@@ -1,1 +1,1 @@".to_string(),
350                old_line_no: None,
351                new_line_no: None,
352            },
353            PatchLine {
354                kind: PatchLineKind::Added,
355                text: "new_code();".to_string(),
356                old_line_no: None,
357                new_line_no: Some(1),
358            },
359        ]);
360        let comments = vec![QueuedComment {
361            file_path: "test.rs".to_string(),
362            patch_ref: PatchLineRef { hunk_index: 0, line_index: 1 },
363            line_text: "new_code();".to_string(),
364            line_number: Some(1),
365            line_kind: PatchLineKind::Added,
366            comment: "looks good".to_string(),
367        }];
368        let context = ViewContext::new((120, 24));
369        let (lines, refs) = build_patch_lines(&file, 80, &context, &comments);
370
371        // Expect: header, added line, comment top border, comment content, comment bottom border
372        assert!(lines.len() > 2, "should have more lines with comment, got {}", lines.len());
373
374        let added_row = 1;
375        let comment_start = added_row + 1;
376        let comment_text = lines[comment_start + 1].plain_text();
377        assert!(
378            comment_text.contains("looks good"),
379            "comment content should contain 'looks good', got: {comment_text}"
380        );
381
382        let top_border = lines[comment_start].plain_text();
383        assert!(top_border.contains('┌'), "comment top border should have ┌, got: {top_border}");
384
385        let bottom_border = lines[comment_start + 2].plain_text();
386        assert!(bottom_border.contains('└'), "comment bottom border should have └, got: {bottom_border}");
387
388        // Comment rows should have None in refs
389        for (i, r) in refs.iter().enumerate().take(comment_start + 3).skip(comment_start) {
390            assert!(r.is_none(), "comment row {i} should have None ref");
391        }
392    }
393
394    #[test]
395    fn inline_comments_after_wrapped_rows() {
396        let long_line = "x".repeat(200);
397        let file = make_file(vec![
398            PatchLine {
399                kind: PatchLineKind::HunkHeader,
400                text: "@@ -1,1 +1,1 @@".to_string(),
401                old_line_no: None,
402                new_line_no: None,
403            },
404            PatchLine { kind: PatchLineKind::Added, text: long_line, old_line_no: None, new_line_no: Some(1) },
405        ]);
406        let comments = vec![QueuedComment {
407            file_path: "test.rs".to_string(),
408            patch_ref: PatchLineRef { hunk_index: 0, line_index: 1 },
409            line_text: "long line".to_string(),
410            line_number: Some(1),
411            line_kind: PatchLineKind::Added,
412            comment: "comment on wrapped".to_string(),
413        }];
414        let context = ViewContext::new((120, 24));
415        let (lines, refs) = build_patch_lines(&file, 60, &context, &comments);
416
417        // Find first comment row (should have ┌)
418        let comment_top =
419            lines.iter().position(|l| l.plain_text().contains('┌')).expect("should find comment top border");
420        // All rows before comment_top (except header at 0) should be wrapped rows or the main line
421        // The first wrapped row should have a ref, continuation rows should have None
422        assert!(refs[1].is_some(), "first wrapped row should have ref");
423
424        // Comment rows should all be None
425        for (i, r) in refs.iter().enumerate().skip(comment_top) {
426            assert!(r.is_none(), "comment row {i} should have None ref");
427        }
428    }
429
430    #[test]
431    fn multiple_comments_same_line_preserve_queue_order() {
432        let file = make_file(vec![
433            PatchLine {
434                kind: PatchLineKind::HunkHeader,
435                text: "@@ -1,1 +1,1 @@".to_string(),
436                old_line_no: None,
437                new_line_no: None,
438            },
439            PatchLine {
440                kind: PatchLineKind::Added,
441                text: "code();".to_string(),
442                old_line_no: None,
443                new_line_no: Some(1),
444            },
445        ]);
446        let comments = vec![
447            QueuedComment {
448                file_path: "test.rs".to_string(),
449                patch_ref: PatchLineRef { hunk_index: 0, line_index: 1 },
450                line_text: "code();".to_string(),
451                line_number: Some(1),
452                line_kind: PatchLineKind::Added,
453                comment: "first".to_string(),
454            },
455            QueuedComment {
456                file_path: "test.rs".to_string(),
457                patch_ref: PatchLineRef { hunk_index: 0, line_index: 1 },
458                line_text: "code();".to_string(),
459                line_number: Some(1),
460                line_kind: PatchLineKind::Added,
461                comment: "second".to_string(),
462            },
463        ];
464        let context = ViewContext::new((120, 24));
465        let (lines, _refs) = build_patch_lines(&file, 80, &context, &comments);
466
467        let text: Vec<String> = lines.iter().map(tui::Line::plain_text).collect();
468        let first_pos = text.iter().position(|t| t.contains("first")).expect("should find 'first' comment");
469        let second_pos = text.iter().position(|t| t.contains("second")).expect("should find 'second' comment");
470        assert!(first_pos < second_pos, "first comment should appear before second");
471    }
472
473    #[test]
474    fn long_comment_text_wraps() {
475        let file = make_file(vec![
476            PatchLine {
477                kind: PatchLineKind::HunkHeader,
478                text: "@@ -1,1 +1,1 @@".to_string(),
479                old_line_no: None,
480                new_line_no: None,
481            },
482            PatchLine {
483                kind: PatchLineKind::Added,
484                text: "code();".to_string(),
485                old_line_no: None,
486                new_line_no: Some(1),
487            },
488        ]);
489        let long_comment = "word ".repeat(50);
490        let comments = vec![QueuedComment {
491            file_path: "test.rs".to_string(),
492            patch_ref: PatchLineRef { hunk_index: 0, line_index: 1 },
493            line_text: "code();".to_string(),
494            line_number: Some(1),
495            line_kind: PatchLineKind::Added,
496            comment: long_comment.trim().to_string(),
497        }];
498        let context = ViewContext::new((120, 24));
499        let (lines, _refs) = build_patch_lines(&file, 40, &context, &comments);
500
501        let content_rows: Vec<_> = lines.iter().skip(2).filter(|l| l.plain_text().contains("word")).collect();
502        assert!(content_rows.len() > 1, "long comment should wrap into multiple rows, got {}", content_rows.len());
503    }
504
505    #[test]
506    fn wrap_text_basic() {
507        let result = wrap_text("hello world foo bar", 10);
508        assert_eq!(result, vec!["hello", "world foo", "bar"]);
509    }
510
511    #[test]
512    fn wrap_text_empty() {
513        let result = wrap_text("", 10);
514        assert_eq!(result, vec![""]);
515    }
516
517    #[test]
518    fn wrap_text_single_word_fits() {
519        let result = wrap_text("hello", 10);
520        assert_eq!(result, vec!["hello"]);
521    }
522
523    #[test]
524    fn wrap_text_zero_width() {
525        let result = wrap_text("hello", 0);
526        assert_eq!(result, vec![""]);
527    }
528}