1use crate::agent::ui::theme::ThemeKey;
2use crate::tui::Theme;
3
4struct ParsedDiffLine<'a> {
7 prefix: char,
8 line_num: &'a str,
9 content: &'a str,
10}
11
12fn parse_diff_line(line: &str) -> Option<ParsedDiffLine<'_>> {
19 let bytes = line.as_bytes();
20 if bytes.is_empty() {
21 return None;
22 }
23 let prefix = bytes[0] as char;
24 if prefix != '+' && prefix != '-' && prefix != ' ' {
25 return None;
26 }
27 let rest = &line[1..];
28 let mut idx = 0;
31 let content_start = 'b: {
32 while idx < rest.len() {
34 let c = rest.as_bytes()[idx] as char;
35 if c == ' ' || c.is_ascii_digit() {
36 idx += 1;
37 } else {
38 break;
39 }
40 }
41 if idx < rest.len() && rest.as_bytes()[idx] == b' ' {
43 break 'b idx + 1;
44 }
45 if idx == 0 {
49 break 'b 0;
50 }
51 break 'b idx;
53 };
54
55 let line_num_part = &rest[..idx].trim();
56 let content = &rest[content_start.min(rest.len())..];
57
58 Some(ParsedDiffLine {
59 prefix,
60 line_num: if line_num_part.is_empty() {
61 ""
62 } else {
63 line_num_part
64 },
65 content,
66 })
67}
68
69pub fn render_diff(diff_text: &str, theme: &dyn Theme) -> Vec<String> {
88 let mut lines: Vec<String> = Vec::new();
89 let diff_lines: Vec<&str> = diff_text.lines().collect();
90 let mut i = 0;
91
92 while i < diff_lines.len() {
93 let raw = diff_lines[i];
94
95 if raw.is_empty() {
96 i += 1;
97 continue;
98 }
99
100 if raw.starts_with("---") || raw.starts_with("+++") || raw.starts_with("@@") {
102 i += 1;
103 continue;
104 }
105
106 let parsed = parse_diff_line(raw);
107 if parsed.is_none() {
108 i += 1;
110 continue;
111 }
112 let parsed = parsed.unwrap();
113
114 if parsed.prefix == '-' {
115 let mut removed: Vec<ParsedDiffLine> = Vec::new();
117 while i < diff_lines.len() {
118 let l = diff_lines[i];
119 if let Some(p) = parse_diff_line(l)
120 && p.prefix == '-'
121 {
122 removed.push(p);
123 i += 1;
124 } else {
125 break;
126 }
127 }
128
129 let mut added: Vec<ParsedDiffLine> = Vec::new();
131 while i < diff_lines.len() {
132 let l = diff_lines[i];
133 if let Some(p) = parse_diff_line(l)
134 && p.prefix == '+'
135 {
136 added.push(p);
137 i += 1;
138 } else {
139 break;
140 }
141 }
142
143 if removed.len() == 1 && added.len() == 1 {
145 render_intra_line_diff(
146 &replace_tabs(removed[0].content),
147 &replace_tabs(added[0].content),
148 &mut lines,
149 theme,
150 );
151 } else {
152 for r in &removed {
154 let content = replace_tabs(r.content);
155 let line_num = r.line_num;
156 if line_num.is_empty() {
157 lines.push(
158 theme.fg_key(ThemeKey::ToolDiffRemoved, &format!("-{}", content)),
159 );
160 } else {
161 lines.push(theme.fg_key(
162 ThemeKey::ToolDiffRemoved,
163 &format!("-{} {}", line_num, content),
164 ));
165 }
166 }
167 for a in &added {
168 let content = replace_tabs(a.content);
169 let line_num = a.line_num;
170 if line_num.is_empty() {
171 lines.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", content)));
172 } else {
173 lines.push(theme.fg_key(
174 ThemeKey::ToolDiffAdded,
175 &format!("+{} {}", line_num, content),
176 ));
177 }
178 }
179 }
180 } else if parsed.prefix == '+' {
181 let content = replace_tabs(parsed.content);
183 let line_num = parsed.line_num;
184 if line_num.is_empty() {
185 lines.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", content)));
186 } else {
187 lines.push(theme.fg_key(
188 ThemeKey::ToolDiffAdded,
189 &format!("+{} {}", line_num, content),
190 ));
191 }
192 i += 1;
193 } else {
194 let content = replace_tabs(parsed.content);
196 let line_num = parsed.line_num;
197 if line_num.is_empty() {
198 lines.push(theme.fg_key(ThemeKey::ToolDiffContext, &format!(" {}", content)));
199 } else {
200 lines.push(theme.fg_key(
201 ThemeKey::ToolDiffContext,
202 &format!(" {} {}", line_num, content),
203 ));
204 }
205 i += 1;
206 }
207 }
208
209 lines
210}
211
212fn replace_tabs(text: &str) -> String {
214 text.replace('\t', " ")
215}
216
217fn render_intra_line_diff(old: &str, new: &str, output: &mut Vec<String>, theme: &dyn Theme) {
224 let changes = compute_word_diff(old, new);
225
226 let mut removed_line = String::new();
227 let mut added_line = String::new();
228
229 for change in &changes {
230 match change {
231 Change::Equal(text) => {
232 removed_line.push_str(text);
233 added_line.push_str(text);
234 }
235 Change::Removed(text) => {
236 let trimmed = text.trim_start();
238 if trimmed.len() < text.len() {
239 let ws = &text[..text.len() - trimmed.len()];
240 removed_line.push_str(ws);
241 }
242 if !trimmed.is_empty() {
243 removed_line.push_str(&theme.inverse(trimmed));
244 }
245 }
246 Change::Added(text) => {
247 let trimmed = text.trim_start();
249 if trimmed.len() < text.len() {
250 let ws = &text[..text.len() - trimmed.len()];
251 added_line.push_str(ws);
252 }
253 if !trimmed.is_empty() {
254 added_line.push_str(&theme.inverse(trimmed));
255 }
256 }
257 }
258 }
259
260 output.push(theme.fg_key(ThemeKey::ToolDiffRemoved, &format!("-{}", removed_line)));
261 output.push(theme.fg_key(ThemeKey::ToolDiffAdded, &format!("+{}", added_line)));
262}
263
264#[derive(Debug)]
266enum Change {
267 Equal(String),
268 Removed(String),
269 Added(String),
270}
271
272fn compute_word_diff(old: &str, new: &str) -> Vec<Change> {
277 let old_tokens = split_words(old);
278 let new_tokens = split_words(new);
279 let n = old_tokens.len();
280 let m = new_tokens.len();
281
282 let tokens_equal = |a: &str, b: &str| a.trim() == b.trim();
285
286 let mut dp = vec![vec![0usize; m + 1]; n + 1];
287 for i in 1..=n {
288 for j in 1..=m {
289 if tokens_equal(&old_tokens[i - 1], &new_tokens[j - 1]) {
290 dp[i][j] = dp[i - 1][j - 1] + 1;
291 } else {
292 dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
293 }
294 }
295 }
296
297 let mut temp = Vec::new();
299 let mut i = n;
300 let mut j = m;
301 while i > 0 || j > 0 {
302 if i > 0 && j > 0 && tokens_equal(&old_tokens[i - 1], &new_tokens[j - 1]) {
303 temp.push(Change::Equal(old_tokens[i - 1].clone()));
304 i -= 1;
305 j -= 1;
306 } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
307 temp.push(Change::Added(new_tokens[j - 1].clone()));
308 j -= 1;
309 } else {
310 temp.push(Change::Removed(old_tokens[i - 1].clone()));
311 i -= 1;
312 }
313 }
314 temp.reverse();
315
316 let mut merged: Vec<Change> = Vec::new();
318 for change in temp {
319 let should_merge = merged.last().is_some_and(|last| {
320 matches!(
321 (last, &change),
322 (Change::Equal(_), Change::Equal(_))
323 | (Change::Removed(_), Change::Removed(_))
324 | (Change::Added(_), Change::Added(_))
325 )
326 });
327
328 if should_merge {
329 if let Some(last) = merged.last_mut() {
330 let text = match change {
331 Change::Equal(t) | Change::Removed(t) | Change::Added(t) => t,
332 };
333 match last {
334 Change::Equal(t) => t.push_str(&text),
335 Change::Removed(t) => t.push_str(&text),
336 Change::Added(t) => t.push_str(&text),
337 }
338 }
339 } else {
340 merged.push(change);
341 }
342 }
343
344 merged
345}
346
347fn split_words(text: &str) -> Vec<String> {
359 let mut parts: Vec<String> = Vec::new();
363 let mut current: Vec<char> = Vec::new();
364 let mut in_whitespace = None;
365
366 for ch in text.chars() {
367 let is_ws = ch.is_whitespace();
368 match in_whitespace {
369 Some(ws) if ws == is_ws => current.push(ch),
370 Some(_) => {
371 parts.push(current.iter().collect());
372 current.clear();
373 current.push(ch);
374 in_whitespace = Some(is_ws);
375 }
376 None => {
377 current.push(ch);
378 in_whitespace = Some(is_ws);
379 }
380 }
381 }
382 if !current.is_empty() {
383 parts.push(current.iter().collect());
384 }
385
386 if parts.is_empty() {
387 return vec![];
388 }
389
390 let mut tokens: Vec<String> = Vec::new();
401 let mut prev_part: Option<&str> = None;
402
403 for part in &parts {
404 if part.is_empty() {
405 continue;
406 }
407 let is_ws = part.chars().all(|c| c.is_whitespace());
408
409 if is_ws {
410 if prev_part.is_none() {
411 tokens.push(part.clone());
413 } else {
414 if let Some(last) = tokens.last_mut() {
416 last.push_str(part);
417 }
418 }
419 } else if let Some(prev) = prev_part {
420 let prev_is_ws = prev.chars().all(|c| c.is_whitespace());
421 if prev_is_ws {
422 if tokens.last().map(|t| t.as_str()) == Some(prev) {
424 tokens.pop();
426 let mut merged = prev.to_string();
427 merged.push_str(part);
428 tokens.push(merged);
429 } else {
430 tokens.push(prev.to_string() + part);
432 }
433 } else {
434 tokens.push(part.clone());
435 }
436 } else {
437 tokens.push(part.clone());
438 }
439
440 prev_part = Some(part.as_str());
441 }
442
443 tokens
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449
450 fn test_theme() -> crate::agent::ui::theme::RabTheme {
451 crate::agent::ui::theme::current_theme().clone()
452 }
453
454 #[test]
455 fn test_empty_diff() {
456 let theme = test_theme();
457 let result = render_diff("", &theme);
458 assert!(result.is_empty());
459 }
460
461 #[test]
462 fn test_skips_headers() {
463 let theme = test_theme();
464 let diff = "--- a/file.rs\n+++ b/file.rs\n@@ -1,3 +1,4 @@\n";
465 let result = render_diff(diff, &theme);
466 assert!(result.is_empty(), "should skip all headers");
467 }
468
469 #[test]
470 fn test_context_lines() {
471 crate::agent::ui::theme::init_theme(Some("dark"), false);
472 let theme = test_theme();
473 let diff = " line1\n line2\n";
474 let result = render_diff(diff, &theme);
475 assert_eq!(result.len(), 2);
476 assert!(result[0].contains("line1"));
477 assert!(result[0].starts_with("\x1b")); assert!(result[0].contains("\x1b[39m")); }
480
481 #[test]
482 fn test_removed_line() {
483 crate::agent::ui::theme::init_theme(Some("dark"), false);
484 let theme = test_theme();
485 let diff = "-old_line\n";
486 let result = render_diff(diff, &theme);
487 assert_eq!(result.len(), 1);
488 assert!(result[0].contains('-'));
490 assert!(result[0].contains("old_line"));
491 }
492
493 #[test]
494 fn test_added_line() {
495 crate::agent::ui::theme::init_theme(Some("dark"), false);
496 let theme = test_theme();
497 let diff = "+new_line\n";
498 let result = render_diff(diff, &theme);
499 assert_eq!(result.len(), 1);
500 assert!(result[0].contains('+'));
501 assert!(result[0].contains("new_line"));
502 }
503
504 #[test]
505 fn test_single_line_modification() {
506 crate::agent::ui::theme::init_theme(Some("dark"), false);
507 let theme = test_theme();
508 let diff = "-foo\n+bar\n";
509 let result = render_diff(diff, &theme);
510 assert_eq!(result.len(), 2);
511 assert!(result[0].contains('-'));
512 assert!(result[1].contains('+'));
513 assert!(
515 result[0].contains("\x1b[7m"),
516 "should have inverse on removed"
517 );
518 assert!(
519 result[1].contains("\x1b[7m"),
520 "should have inverse on added"
521 );
522 }
523
524 #[test]
525 fn test_multi_line_removes() {
526 crate::agent::ui::theme::init_theme(Some("dark"), false);
527 let theme = test_theme();
528 let diff = "-a\n-b\n+c\n";
529 let result = render_diff(diff, &theme);
530 assert_eq!(result.len(), 3);
532 assert!(result[0].contains("-a"));
533 assert!(result[1].contains("-b"));
534 assert!(result[2].contains("+c"));
535 }
536
537 #[test]
538 fn test_multi_line_removes_no_intra_diff() {
539 crate::agent::ui::theme::init_theme(Some("dark"), false);
540 let theme = test_theme();
541 let diff = "-aaa\n-bbb\n+ccc\n+ddd\n";
542 let result = render_diff(diff, &theme);
543 assert_eq!(result.len(), 4);
544 assert!(
546 !result[0].contains("\x1b[7m"),
547 "no inverse on multi-line remove"
548 );
549 }
550
551 #[test]
552 fn test_compute_word_diff_basic() {
553 let changes = compute_word_diff("abc", "abd");
554 assert!(!changes.is_empty());
555 }
556
557 #[test]
558 fn test_compute_word_diff_identical() {
559 let changes = compute_word_diff("hello", "hello");
560 assert_eq!(changes.len(), 1);
561 assert!(matches!(changes[0], Change::Equal(_)));
562 }
563
564 #[test]
565 fn test_tabs_replaced() {
566 crate::agent::ui::theme::init_theme(Some("dark"), false);
567 let theme = test_theme();
568 let diff = "-\tindented\n";
569 let result = render_diff(diff, &theme);
570 assert_eq!(result.len(), 1);
571 assert!(!result[0].contains('\t'), "tabs should be replaced");
572 }
573
574 #[test]
575 fn test_context_line_format() {
576 crate::agent::ui::theme::init_theme(Some("dark"), false);
577 let theme = test_theme();
578 let diff = " context\n";
579 let result = render_diff(diff, &theme);
580 assert_eq!(result.len(), 1);
581 assert!(result[0].contains("context"));
582 assert!(result[0].starts_with("\x1b"));
583 }
584}