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 assert!(lines.len() > 2, "long line should wrap, got {} lines", lines.len());
267
268 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 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 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 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 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 let comment_top =
419 lines.iter().position(|l| l.plain_text().contains('┌')).expect("should find comment top border");
420 assert!(refs[1].is_some(), "first wrapped row should have ref");
423
424 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}