返回 DeepSeek-TUI-2026
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::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 mut old_line: Option<usize> = None;
22 let mut new_line: Option<usize> = None;
23 let summaries = summarize_diff(diff);
24
25 if !summaries.is_empty() {
26 lines.extend(render_diff_summary(&summaries, width));
27 }
28
29 for raw in diff.lines() {
30 if raw.starts_with("diff --git") || raw.starts_with("index ") {
31 lines.extend(render_header_line(raw, width));
32 continue;
33 }
34
35 if raw.starts_with("--- ") || raw.starts_with("+++ ") {
36 lines.extend(render_header_line(raw, width));
37 continue;
38 }
39
40 if raw.starts_with("@@") {
41 if let Some((old_start, new_start)) = parse_hunk_header(raw) {
42 old_line = Some(old_start);
43 new_line = Some(new_start);
44 }
45 lines.extend(render_hunk_header(raw, width));
46 continue;
47 }
48
49 if raw.starts_with('+') && !raw.starts_with("+++") {
50 let content = raw.trim_start_matches('+');
51 lines.extend(render_diff_line(
52 content,
53 width,
54 old_line,
55 new_line,
56 '+',
57 Style::default()
58 .fg(palette::DIFF_ADDED)
59 .bg(palette::DIFF_ADDED_BG),
60 ));
61 if let Some(line) = new_line.as_mut() {
62 *line = line.saturating_add(1);
63 }
64 continue;
65 }
66
67 if raw.starts_with('-') && !raw.starts_with("---") {
68 let content = raw.trim_start_matches('-');
69 lines.extend(render_diff_line(
70 content,
71 width,
72 old_line,
73 new_line,
74 '-',
75 Style::default()
76 .fg(palette::STATUS_ERROR)
77 .bg(palette::DIFF_DELETED_BG),
78 ));
79 if let Some(line) = old_line.as_mut() {
80 *line = line.saturating_add(1);
81 }
82 continue;
83 }
84
85 if raw.starts_with(' ') {
86 let content = raw.trim_start_matches(' ');
87 lines.extend(render_diff_line(
88 content,
89 width,
90 old_line,
91 new_line,
92 ' ',
93 Style::default().fg(palette::TEXT_PRIMARY),
94 ));
95 if let Some(line) = old_line.as_mut() {
96 *line = line.saturating_add(1);
97 }
98 if let Some(line) = new_line.as_mut() {
99 *line = line.saturating_add(1);
100 }
101 continue;
102 }
103
104 lines.extend(render_header_line(raw, width));
105 }
106
107 lines
108 }
109
110 #[must_use]
111 pub fn summarize_diff(diff: &str) -> Vec<DiffFileSummary> {
112 let mut summaries = Vec::new();
113 let mut current: Option<DiffFileSummary> = None;
114
115 for raw in diff.lines() {
116 if raw.starts_with("diff --git ") {
117 if let Some(summary) = current.take()
118 && summary.has_changes()
119 {
120 summaries.push(summary);
121 }
122 current = Some(DiffFileSummary {
123 path: parse_diff_git_path(raw).unwrap_or_else(|| "<file>".to_string()),
124 added: 0,
125 deleted: 0,
126 hunks: 0,
127 });
128 continue;
129 }
130
131 if raw.starts_with("+++ ") {
132 let path = raw
133 .trim_start_matches("+++ ")
134 .trim_start_matches("b/")
135 .to_string();
136 if path != "/dev/null" {
137 current
138 .get_or_insert_with(|| DiffFileSummary {
139 path: path.clone(),
140 added: 0,
141 deleted: 0,
142 hunks: 0,
143 })
144 .path = path.clone();
145 }
146 continue;
147 }
148
149 if raw.starts_with("@@") {
150 current
151 .get_or_insert_with(|| DiffFileSummary {
152 path: "<file>".to_string(),
153 added: 0,
154 deleted: 0,
155 hunks: 0,
156 })
157 .hunks += 1;
158 continue;
159 }
160
161 if raw.starts_with('+') && !raw.starts_with("+++") {
162 current
163 .get_or_insert_with(|| DiffFileSummary {
164 path: "<file>".to_string(),
165 added: 0,
166 deleted: 0,
167 hunks: 0,
168 })
169 .added += 1;
170 } else if raw.starts_with('-') && !raw.starts_with("---") {
171 current
172 .get_or_insert_with(|| DiffFileSummary {
173 path: "<file>".to_string(),
174 added: 0,
175 deleted: 0,
176 hunks: 0,
177 })
178 .deleted += 1;
179 }
180 }
181
182 if let Some(summary) = current
183 && summary.has_changes()
184 {
185 summaries.push(summary);
186 }
187
188 summaries
189 }
190
191 #[must_use]
192 pub fn diff_summary_label(diff: &str) -> Option<String> {
193 let summaries = summarize_diff(diff);
194 if summaries.is_empty() {
195 return None;
196 }
197 let files = summaries.len();
198 let added: usize = summaries.iter().map(|summary| summary.added).sum();
199 let deleted: usize = summaries.iter().map(|summary| summary.deleted).sum();
200 Some(format!(
201 "{files} file{} +{added} -{deleted}",
202 if files == 1 { "" } else { "s" }
203 ))
204 }
205
206 impl DiffFileSummary {
207 fn has_changes(&self) -> bool {
208 self.added > 0 || self.deleted > 0 || self.hunks > 0
209 }
210 }
211
212 fn parse_diff_git_path(line: &str) -> Option<String> {
213 let mut parts = line.split_whitespace();
214 let _diff = parts.next()?;
215 let _git = parts.next()?;
216 let _old = parts.next()?;
217 let new = parts.next()?;
218 Some(new.trim_start_matches("b/").to_string())
219 }
220
221 fn render_diff_summary(summaries: &[DiffFileSummary], width: u16) -> Vec<Line<'static>> {
222 let files = summaries.len();
223 let added: usize = summaries.iter().map(|summary| summary.added).sum();
224 let deleted: usize = summaries.iter().map(|summary| summary.deleted).sum();
225 let hunks: usize = summaries.iter().map(|summary| summary.hunks).sum();
226
227 let mut lines = Vec::new();
228 lines.extend(wrap_with_style(
229 &format!(
230 "summary: {files} file{}, +{added} -{deleted}, {hunks} hunk{}",
231 if files == 1 { "" } else { "s" },
232 if hunks == 1 { "" } else { "s" },
233 ),
234 Style::default()
235 .fg(palette::TEXT_PRIMARY)
236 .add_modifier(Modifier::BOLD),
237 width,
238 ));
239 for summary in summaries {
240 let row = format!(
241 " {} +{} -{} {} hunk{}",
242 summary.path,
243 summary.added,
244 summary.deleted,
245 summary.hunks,
246 if summary.hunks == 1 { "" } else { "s" },
247 );
248 lines.extend(wrap_with_style(
249 &row,
250 Style::default().fg(palette::TEXT_MUTED),
251 width,
252 ));
253 }
254 lines
255 }
256
257 fn parse_hunk_header(line: &str) -> Option<(usize, usize)> {
258 let parts: Vec<&str> = line.split_whitespace().collect();
259 if parts.len() < 3 {
260 return None;
261 }
262 let old = parts[1].trim_start_matches('-');
263 let new = parts[2].trim_start_matches('+');
264 let old_start = old.split(',').next()?.parse::<usize>().ok()?;
265 let new_start = new.split(',').next()?.parse::<usize>().ok()?;
266 Some((old_start, new_start))
267 }
268
269 fn render_header_line(line: &str, width: u16) -> Vec<Line<'static>> {
270 let style = Style::default()
271 .fg(palette::DEEPSEEK_SKY)
272 .add_modifier(Modifier::BOLD);
273 wrap_with_style(line, style, width)
274 }
275
276 fn render_hunk_header(line: &str, width: u16) -> Vec<Line<'static>> {
277 let style = Style::default().fg(palette::DEEPSEEK_BLUE);
278 wrap_with_style(line, style, width)
279 }
280
281 fn render_diff_line(
282 content: &str,
283 width: u16,
284 old_line: Option<usize>,
285 new_line: Option<usize>,
286 marker: char,
287 style: Style,
288 ) -> Vec<Line<'static>> {
289 let prefix = format_line_numbers(old_line, new_line, marker);
290 let prefix_width = prefix.width();
291 let available = width.saturating_sub(prefix_width as u16).max(1) as usize;
292 let wrapped = wrap_text(content, available);
293
294 let mut out = Vec::new();
295 for (idx, chunk) in wrapped.into_iter().enumerate() {
296 if idx == 0 {
297 out.push(Line::from(vec![
298 Span::styled(prefix.clone(), Style::default().fg(palette::TEXT_MUTED)),
299 Span::styled(chunk, style),
300 ]));
301 } else {
302 out.push(Line::from(vec![
303 Span::raw(" ".repeat(prefix_width)),
304 Span::styled(chunk, style),
305 ]));
306 }
307 }
308
309 if out.is_empty() {
310 out.push(Line::from(vec![Span::styled(
311 prefix,
312 Style::default().fg(palette::TEXT_MUTED),
313 )]));
314 }
315
316 out
317 }
318
319 fn format_line_numbers(old_line: Option<usize>, new_line: Option<usize>, marker: char) -> String {
320 let old = old_line
321 .map(|value| {
322 format!(
323 "{value:>LINE_NUMBER_WIDTH$}",
324 LINE_NUMBER_WIDTH = LINE_NUMBER_WIDTH
325 )
326 })
327 .unwrap_or_else(|| " ".repeat(LINE_NUMBER_WIDTH));
328 let new = new_line
329 .map(|value| {
330 format!(
331 "{value:>LINE_NUMBER_WIDTH$}",
332 LINE_NUMBER_WIDTH = LINE_NUMBER_WIDTH
333 )
334 })
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 mut lines = Vec::new();
355 let mut current = String::new();
356 let mut current_width = 0;
357
358 for word in text.split_whitespace() {
359 let word_width = word.width();
360 let additional = if current.is_empty() {
361 word_width
362 } else {
363 word_width + 1
364 };
365 if current_width + additional > width && !current.is_empty() {
366 lines.push(current);
367 current = word.to_string();
368 current_width = word_width;
369 } else {
370 if !current.is_empty() {
371 current.push(' ');
372 current_width += 1;
373 }
374 current.push_str(word);
375 current_width += word_width;
376 }
377 }
378
379 if current.is_empty() {
380 lines.push(String::new());
381 } else {
382 lines.push(current);
383 }
384
385 lines
386 }
387
388 #[cfg(test)]
389 mod tests {
390 use super::*;
391
392 fn line_text(line: &Line<'static>) -> String {
393 line.spans
394 .iter()
395 .map(|span| span.content.as_ref())
396 .collect()
397 }
398
399 #[test]
400 fn summarizes_multi_file_diff() {
401 let diff = "\
402 diff --git a/src/a.rs b/src/a.rs
403 --- a/src/a.rs
404 +++ b/src/a.rs
405 @@ -1,2 +1,3 @@
406 line
407 +new
408 -old
409 diff --git a/src/b.rs b/src/b.rs
410 --- a/src/b.rs
411 +++ b/src/b.rs
412 @@ -10,0 +11,2 @@
413 +one
414 +two
415 ";
416
417 let summaries = summarize_diff(diff);
418 assert_eq!(summaries.len(), 2);
419 assert_eq!(summaries[0].path, "src/a.rs");
420 assert_eq!(summaries[0].added, 1);
421 assert_eq!(summaries[0].deleted, 1);
422 assert_eq!(summaries[1].path, "src/b.rs");
423 assert_eq!(summaries[1].added, 2);
424 assert_eq!(summaries[1].deleted, 0);
425 assert_eq!(diff_summary_label(diff).as_deref(), Some("2 files +3 -1"));
426 }
427
428 #[test]
429 fn render_diff_prepends_summary_and_gutter_markers() {
430 let diff = "\
431 diff --git a/src/a.rs b/src/a.rs
432 --- a/src/a.rs
433 +++ b/src/a.rs
434 @@ -1,2 +1,3 @@
435 line
436 +new
437 -old
438 ";
439
440 let rendered = render_diff(diff, 80);
441 let text = rendered.iter().map(line_text).collect::<Vec<_>>();
442 assert!(text[0].contains("summary: 1 file, +1 -1, 1 hunk"));
443 assert!(text.iter().any(|line| line.contains("src/a.rs +1 -1")));
444 assert!(
445 text.iter().any(|line| line.contains(" + new")),
446 "added line should carry + gutter: {text:?}"
447 );
448 assert!(
449 text.iter().any(|line| line.contains(" - old")),
450 "deleted line should carry - gutter: {text:?}"
451 );
452 }
453 }
454
454 lines RUST