返回 CodeWhale
diff_render.rs
根目录 / crates / tui / src / tui / diff_render.rs
1 //! Diff rendering helpers for TUI previews.
2
3 use ratatui::style::{Modifier, Style};
4 use ratatui::text::{Line, Span};
5 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
6
7 use crate::palette;
8
9 const LINE_NUMBER_WIDTH: usize = 4;
10
11 #[derive(Debug, Clone, PartialEq, Eq)]
12 pub struct DiffFileSummary {
13 pub path: String,
14 pub added: usize,
15 pub deleted: usize,
16 pub hunks: usize,
17 }
18
19 pub fn render_diff(diff: &str, width: u16) -> Vec<Line<'static>> {
20 let mut lines = Vec::new();
21 let summaries = summarize_diff(diff);
22
23 if !summaries.is_empty() {
24 lines.extend(render_diff_summary(&summaries, width));
25 }
26 lines.extend(render_diff_body(diff, width));
27 lines
28 }
29
30 /// Render only the diff body. Callers that already own a semantic summary use
31 /// this form so the bounded preview budget is spent on the actual red/green
32 /// evidence instead of a second, generic summary.
33 #[must_use]
34 pub fn render_diff_body(diff: &str, width: u16) -> Vec<Line<'static>> {
35 let mut lines = Vec::new();
36 let mut old_line: Option<usize> = None;
37 let mut new_line: Option<usize> = None;
38
39 for raw in diff.lines() {
40 if raw.starts_with("diff --git") || raw.starts_with("index ") {
41 lines.extend(render_header_line(raw, width));
42 continue;
43 }
44
45 if raw.starts_with("--- ") || raw.starts_with("+++ ") {
46 lines.extend(render_header_line(raw, width));
47 continue;
48 }
49
50 if raw.starts_with("@@") {
51 if let Some((old_start, new_start)) = parse_hunk_header(raw) {
52 old_line = Some(old_start);
53 new_line = Some(new_start);
54 }
55 lines.extend(render_hunk_header(raw, width));
56 continue;
57 }
58
59 if raw.starts_with('+') && !raw.starts_with("+++") {
60 let content = raw.trim_start_matches('+');
61 lines.extend(render_diff_line(
62 content,
63 width,
64 old_line,
65 new_line,
66 '+',
67 Style::default()
68 .fg(palette::DIFF_ADDED)
69 .bg(palette::DIFF_ADDED_BG),
70 ));
71 if let Some(line) = new_line.as_mut() {
72 *line = line.saturating_add(1);
73 }
74 continue;
75 }
76
77 if raw.starts_with('-') && !raw.starts_with("---") {
78 let content = raw.trim_start_matches('-');
79 lines.extend(render_diff_line(
80 content,
81 width,
82 old_line,
83 new_line,
84 '-',
85 Style::default()
86 .fg(palette::STATUS_ERROR)
87 .bg(palette::DIFF_DELETED_BG),
88 ));
89 if let Some(line) = old_line.as_mut() {
90 *line = line.saturating_add(1);
91 }
92 continue;
93 }
94
95 if raw.starts_with(' ') {
96 let content = raw.trim_start_matches(' ');
97 lines.extend(render_diff_line(
98 content,
99 width,
100 old_line,
101 new_line,
102 ' ',
103 Style::default().fg(palette::TEXT_PRIMARY),
104 ));
105 if let Some(line) = old_line.as_mut() {
106 *line = line.saturating_add(1);
107 }
108 if let Some(line) = new_line.as_mut() {
109 *line = line.saturating_add(1);
110 }
111 continue;
112 }
113
114 lines.extend(render_header_line(raw, width));
115 }
116
117 lines
118 }
119
120 #[must_use]
121 pub fn summarize_diff(diff: &str) -> Vec<DiffFileSummary> {
122 let mut summaries = Vec::new();
123 let mut current: Option<DiffFileSummary> = None;
124
125 for raw in diff.lines() {
126 if raw.starts_with("diff --git ") {
127 if let Some(summary) = current.take()
128 && summary.has_changes()
129 {
130 summaries.push(summary);
131 }
132 current = Some(DiffFileSummary {
133 path: parse_diff_git_path(raw).unwrap_or_else(|| "<file>".to_string()),
134 added: 0,
135 deleted: 0,
136 hunks: 0,
137 });
138 continue;
139 }
140
141 if raw.starts_with("+++ ") {
142 let path = raw
143 .trim_start_matches("+++ ")
144 .trim_start_matches("b/")
145 .to_string();
146 if path != "/dev/null" {
147 current
148 .get_or_insert_with(|| DiffFileSummary {
149 path: path.clone(),
150 added: 0,
151 deleted: 0,
152 hunks: 0,
153 })
154 .path = path.clone();
155 }
156 continue;
157 }
158
159 if raw.starts_with("@@") {
160 current
161 .get_or_insert_with(|| DiffFileSummary {
162 path: "<file>".to_string(),
163 added: 0,
164 deleted: 0,
165 hunks: 0,
166 })
167 .hunks += 1;
168 continue;
169 }
170
171 if raw.starts_with('+') && !raw.starts_with("+++") {
172 current
173 .get_or_insert_with(|| DiffFileSummary {
174 path: "<file>".to_string(),
175 added: 0,
176 deleted: 0,
177 hunks: 0,
178 })
179 .added += 1;
180 } else if raw.starts_with('-') && !raw.starts_with("---") {
181 current
182 .get_or_insert_with(|| DiffFileSummary {
183 path: "<file>".to_string(),
184 added: 0,
185 deleted: 0,
186 hunks: 0,
187 })
188 .deleted += 1;
189 }
190 }
191
192 if let Some(summary) = current
193 && summary.has_changes()
194 {
195 summaries.push(summary);
196 }
197
198 summaries
199 }
200
201 #[must_use]
202 pub fn diff_summary_label(diff: &str) -> Option<String> {
203 let summaries = summarize_diff(diff);
204 if summaries.is_empty() {
205 return None;
206 }
207 let files = summaries.len();
208 let added: usize = summaries.iter().map(|summary| summary.added).sum();
209 let deleted: usize = summaries.iter().map(|summary| summary.deleted).sum();
210 Some(format!(
211 "{files} file{} +{added} -{deleted}",
212 if files == 1 { "" } else { "s" }
213 ))
214 }
215
216 impl DiffFileSummary {
217 fn has_changes(&self) -> bool {
218 self.added > 0 || self.deleted > 0 || self.hunks > 0
219 }
220 }
221
222 fn parse_diff_git_path(line: &str) -> Option<String> {
223 let mut parts = line.split_whitespace();
224 let _diff = parts.next()?;
225 let _git = parts.next()?;
226 let _old = parts.next()?;
227 let new = parts.next()?;
228 Some(new.trim_start_matches("b/").to_string())
229 }
230
231 fn render_diff_summary(summaries: &[DiffFileSummary], width: u16) -> Vec<Line<'static>> {
232 let files = summaries.len();
233 let added: usize = summaries.iter().map(|summary| summary.added).sum();
234 let deleted: usize = summaries.iter().map(|summary| summary.deleted).sum();
235 let hunks: usize = summaries.iter().map(|summary| summary.hunks).sum();
236
237 let mut lines = Vec::new();
238 lines.extend(wrap_with_style(
239 &format!(
240 "summary: {files} file{}, +{added} -{deleted}, {hunks} hunk{}",
241 if files == 1 { "" } else { "s" },
242 if hunks == 1 { "" } else { "s" },
243 ),
244 Style::default()
245 .fg(palette::TEXT_PRIMARY)
246 .add_modifier(Modifier::BOLD),
247 width,
248 ));
249 for summary in summaries {
250 let row = format!(
251 " {} +{} -{} {} hunk{}",
252 summary.path,
253 summary.added,
254 summary.deleted,
255 summary.hunks,
256 if summary.hunks == 1 { "" } else { "s" },
257 );
258 lines.extend(wrap_with_style(
259 &row,
260 Style::default().fg(palette::TEXT_MUTED),
261 width,
262 ));
263 }
264 lines
265 }
266
267 fn parse_hunk_header(line: &str) -> Option<(usize, usize)> {
268 let parts: Vec<&str> = line.split_whitespace().collect();
269 if parts.len() < 3 {
270 return None;
271 }
272 let old = parts[1].trim_start_matches('-');
273 let new = parts[2].trim_start_matches('+');
274 let old_start = old.split(',').next()?.parse::<usize>().ok()?;
275 let new_start = new.split(',').next()?.parse::<usize>().ok()?;
276 Some((old_start, new_start))
277 }
278
279 fn render_header_line(line: &str, width: u16) -> Vec<Line<'static>> {
280 let style = Style::default()
281 .fg(palette::WHALE_INFO)
282 .add_modifier(Modifier::BOLD);
283 wrap_with_style(line, style, width)
284 }
285
286 fn render_hunk_header(line: &str, width: u16) -> Vec<Line<'static>> {
287 let style = Style::default().fg(palette::WHALE_ACTION);
288 wrap_with_style(line, style, width)
289 }
290
291 fn render_diff_line(
292 content: &str,
293 width: u16,
294 old_line: Option<usize>,
295 new_line: Option<usize>,
296 marker: char,
297 style: Style,
298 ) -> Vec<Line<'static>> {
299 let prefix = format_line_numbers(old_line, new_line, marker);
300 let prefix_width = prefix.width();
301 let available = width.saturating_sub(prefix_width as u16).max(1) as usize;
302 let wrapped = wrap_text(content, available);
303
304 let mut out = Vec::new();
305 for (idx, chunk) in wrapped.into_iter().enumerate() {
306 if idx == 0 {
307 out.push(Line::from(vec![
308 Span::styled(prefix.clone(), Style::default().fg(palette::TEXT_MUTED)),
309 Span::styled(chunk, style),
310 ]));
311 } else {
312 out.push(Line::from(vec![
313 Span::raw(" ".repeat(prefix_width)),
314 Span::styled(chunk, style),
315 ]));
316 }
317 }
318
319 if out.is_empty() {
320 out.push(Line::from(vec![Span::styled(
321 prefix,
322 Style::default().fg(palette::TEXT_MUTED),
323 )]));
324 }
325
326 out
327 }
328
329 fn format_line_numbers(old_line: Option<usize>, new_line: Option<usize>, marker: char) -> String {
330 let old = old_line
331 .map(|value| format!("{value:>LINE_NUMBER_WIDTH$}"))
332 .unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH));
333 let new = new_line
334 .map(|value| format!("{value:>LINE_NUMBER_WIDTH$}"))
335 .unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH));
336 format!("{old} {new} {marker} ")
337 }
338
339 fn wrap_with_style(text: &str, style: Style, width: u16) -> Vec<Line<'static>> {
340 let mut out = Vec::new();
341 for part in wrap_text(text, width.max(1) as usize) {
342 out.push(Line::from(Span::styled(part, style)));
343 }
344 if out.is_empty() {
345 out.push(Line::from(Span::styled("", style)));
346 }
347 out
348 }
349
350 fn wrap_text(text: &str, width: usize) -> Vec<String> {
351 if width == 0 {
352 return vec![text.to_string()];
353 }
354 let lead = text
355 .chars()
356 .take_while(|ch| ch.is_whitespace())
357 .collect::<String>();
358 let trimmed = text.trim_start();
359 if trimmed.is_empty() {
360 return vec![text.to_string()];
361 }
362
363 let mut lines = Vec::new();
364 let lead_width = lead.width();
365 let mut current = lead.clone();
366 let mut current_width = lead_width;
367 let mut has_word = false;
368
369 for word in trimmed.split_whitespace() {
370 let word_width = word.width();
371 if word_width > width {
372 if has_word {
373 lines.push(std::mem::take(&mut current));
374 current = lead.clone();
375 current_width = lead_width;
376 }
377 push_word_breaking_chars(word, width, &mut current, &mut current_width, &mut lines);
378 has_word = current_width > lead_width;
379 continue;
380 }
381 let additional = if has_word { word_width + 1 } else { word_width };
382 if current_width + additional > width && has_word {
383 lines.push(current);
384 current = lead.clone();
385 current_width = lead_width;
386 has_word = false;
387 }
388 if has_word {
389 current.push(' ');
390 current_width += 1;
391 }
392 if current_width + word_width > width && !has_word && lead_width > 0 {
393 lines.push(std::mem::take(&mut current));
394 current_width = 0;
395 }
396 if current_width == 0 && lead_width > 0 && word_width + lead_width <= width {
397 current = lead.clone();
398 current_width = lead_width;
399 }
400 current.push_str(word);
401 current_width += word_width;
402 has_word = true;
403 }
404
405 if has_word || !current.is_empty() {
406 lines.push(current);
407 } else {
408 lines.push(String::new());
409 }
410
411 lines
412 }
413
414 fn push_word_breaking_chars(
415 word: &str,
416 width: usize,
417 current: &mut String,
418 current_width: &mut usize,
419 lines: &mut Vec<String>,
420 ) {
421 for ch in word.chars() {
422 let char_width = ch.width().unwrap_or(1);
423 if *current_width + char_width > width && *current_width > 0 {
424 lines.push(std::mem::take(current));
425 *current_width = 0;
426 }
427 current.push(ch);
428 *current_width += char_width;
429 }
430 }
431
432 #[cfg(test)]
433 mod tests {
434 use super::*;
435
436 fn line_text(line: &Line<'static>) -> String {
437 line.spans
438 .iter()
439 .map(|span| span.content.as_ref())
440 .collect()
441 }
442
443 fn diff_content_text(line: &Line<'static>) -> Option<String> {
444 line.spans.get(1).map(|span| span.content.to_string())
445 }
446
447 #[test]
448 fn summarizes_multi_file_diff() {
449 let diff = "\
450 diff --git a/src/a.rs b/src/a.rs
451 --- a/src/a.rs
452 +++ b/src/a.rs
453 @@ -1,2 +1,3 @@
454 line
455 +new
456 -old
457 diff --git a/src/b.rs b/src/b.rs
458 --- a/src/b.rs
459 +++ b/src/b.rs
460 @@ -10,0 +11,2 @@
461 +one
462 +two
463 ";
464
465 let summaries = summarize_diff(diff);
466 assert_eq!(summaries.len(), 2);
467 assert_eq!(summaries[0].path, "src/a.rs");
468 assert_eq!(summaries[0].added, 1);
469 assert_eq!(summaries[0].deleted, 1);
470 assert_eq!(summaries[1].path, "src/b.rs");
471 assert_eq!(summaries[1].added, 2);
472 assert_eq!(summaries[1].deleted, 0);
473 assert_eq!(diff_summary_label(diff).as_deref(), Some("2 files +3 -1"));
474 }
475
476 #[test]
477 fn render_diff_prepends_summary_and_gutter_markers() {
478 let diff = "\
479 diff --git a/src/a.rs b/src/a.rs
480 --- a/src/a.rs
481 +++ b/src/a.rs
482 @@ -1,2 +1,3 @@
483 line
484 +new
485 -old
486 ";
487
488 let rendered = render_diff(diff, 80);
489 let text = rendered.iter().map(line_text).collect::<Vec<_>>();
490 assert!(text[0].contains("summary: 1 file, +1 -1, 1 hunk"));
491 assert!(text.iter().any(|line| line.contains("src/a.rs +1 -1")));
492 assert!(
493 text.iter().any(|line| line.contains(" + new")),
494 "added line should carry + gutter: {text:?}"
495 );
496 assert!(
497 text.iter().any(|line| line.contains(" - old")),
498 "deleted line should carry - gutter: {text:?}"
499 );
500 }
501
502 #[test]
503 fn wrap_text_preserves_leading_whitespace_without_extra_space() {
504 assert_eq!(wrap_text(" let y = 2;", 80), vec![" let y = 2;"]);
505 assert_eq!(
506 wrap_text(" println!(\"hello\");", 80),
507 vec![" println!(\"hello\");"]
508 );
509 }
510
511 #[test]
512 fn render_diff_preserves_leading_whitespace_exactly() {
513 let diff = "\
514 diff --git a/src/lib.rs b/src/lib.rs
515 --- a/src/lib.rs
516 +++ b/src/lib.rs
517 @@ -1,2 +1,3 @@
518 fn main() {
519 + let y = 2;
520 + println!(\"{y}\");
521 }
522 ";
523
524 let rendered = render_diff(diff, 80);
525 let content = rendered
526 .iter()
527 .filter_map(diff_content_text)
528 .collect::<Vec<_>>();
529
530 assert!(
531 content.iter().any(|line| line == " let y = 2;"),
532 "added line should keep exact 4-space indent: {content:?}"
533 );
534 assert!(
535 content
536 .iter()
537 .any(|line| line == " println!(\"{y}\");"),
538 "added line should keep exact 8-space indent: {content:?}"
539 );
540 }
541
542 #[test]
543 fn wrap_text_breaks_overlong_cjk_runs() {
544 let text = "这是一个非常长的中文字符串".repeat(10);
545 let lines = wrap_text(&text, 16);
546
547 for line in &lines {
548 assert!(line.width() <= 16, "line {line:?} exceeds width 16");
549 }
550
551 assert_eq!(lines.join(""), text);
552 }
553 }
554
554 lines RUST