1use crate::syntax::SyntaxHighlighter;
4use tracing::debug;
5
6use ratatui::{
7 buffer::Buffer,
8 layout::Rect,
9 prelude::Widget,
10 style::{Color, Style},
11 text::{Line, Span},
12};
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum DiffType {
16 Addition,
18 Deletion,
20 Context,
22 Header,
24}
25
26#[derive(Debug, Clone)]
28pub struct DiffLine {
29 pub line_type: DiffType,
31 pub content: String,
33 pub old_line: Option<usize>,
35 pub new_line: Option<usize>,
37}
38
39impl DiffLine {
40 pub fn new(line_type: DiffType, content: String) -> Self {
42 Self {
43 line_type,
44 content,
45 old_line: None,
46 new_line: None,
47 }
48 }
49
50 pub fn with_old_line(mut self, line: usize) -> Self {
52 self.old_line = Some(line);
53 self
54 }
55
56 pub fn with_new_line(mut self, line: usize) -> Self {
58 self.new_line = Some(line);
59 self
60 }
61}
62
63#[derive(Clone)]
65pub struct DiffView {
66 lines: Vec<DiffLine>,
68 scroll_offset: usize,
70 highlighter: SyntaxHighlighter,
72}
73
74impl DiffView {
75 fn detect_language_from_diff(&self) -> &str {
77 for line in &self.lines {
79 if line.content.ends_with(".rs") || line.content.contains(".rs ") {
80 return "rust";
81 }
82 if line.content.ends_with(".py") || line.content.contains(".py ") {
83 return "python";
84 }
85 if line.content.ends_with(".js") || line.content.contains(".js ") {
86 return "javascript";
87 }
88 if line.content.ends_with(".ts") || line.content.contains(".ts ") {
89 return "typescript";
90 }
91 if line.content.ends_with(".go") || line.content.contains(".go ") {
92 return "go";
93 }
94 if line.content.ends_with(".java") || line.content.contains(".java ") {
95 return "java";
96 }
97 if line.content.ends_with(".cpp")
98 || line.content.contains(".cpp ")
99 || line.content.ends_with(".cc")
100 || line.content.contains(".cc ")
101 || line.content.ends_with(".cxx")
102 || line.content.contains(".cxx ")
103 {
104 return "cpp";
105 }
106 if line.content.ends_with(".c")
107 && !line.content.ends_with(".cpp")
108 && !line.content.ends_with(".cxx")
109 {
110 return "c";
111 }
112 if line.content.ends_with(".json") || line.content.contains(".json ") {
113 return "json";
114 }
115 if line.content.ends_with(".yaml")
116 || line.content.contains(".yaml ")
117 || line.content.ends_with(".yml")
118 || line.content.contains(".yml ")
119 {
120 return "yaml";
121 }
122 if line.content.ends_with(".toml") || line.content.contains(".toml ") {
123 return "toml";
124 }
125 if line.content.ends_with(".sh")
126 || line.content.contains(".sh ")
127 || line.content.ends_with(".bash")
128 || line.content.contains(".bash ")
129 {
130 return "bash";
131 }
132 }
133 ""
135 }
136 pub fn new() -> Self {
138 debug!(component = %"DiffView", "Component created");
139 Self {
140 lines: Vec::new(),
141 scroll_offset: 0,
142 highlighter: SyntaxHighlighter::new().expect("Failed to initialize syntax highlighter"),
143 }
144 }
145
146 pub fn from_diff(diff_text: &str) -> Self {
148 debug!(component = %"DiffView", "Component created");
149 Self {
150 lines: parse_diff(diff_text),
151 scroll_offset: 0,
152 highlighter: SyntaxHighlighter::new().expect("Failed to initialize syntax highlighter"),
153 }
154 }
155
156 pub fn set_diff(&mut self, diff_text: &str) {
159 self.lines = parse_diff(diff_text);
160 self.scroll_offset = 0;
161 }
162
163 pub fn len(&self) -> usize {
165 self.lines.len()
166 }
167
168 pub fn is_empty(&self) -> bool {
170 self.lines.is_empty()
171 }
172
173 pub fn scroll_offset(&self) -> usize {
175 self.scroll_offset
176 }
177
178 pub fn scroll_up(&mut self) {
180 if self.scroll_offset > 0 {
181 self.scroll_offset -= 1;
182 }
183 }
184
185 pub fn scroll_down(&mut self) {
187 if self.scroll_offset < self.lines.len().saturating_sub(1) {
188 self.scroll_offset += 1;
189 }
190 }
191
192 pub fn page_up(&mut self, page_size: usize) {
194 self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
195 }
196
197 pub fn page_down(&mut self, page_size: usize) {
199 let max_offset = self.lines.len().saturating_sub(1);
200 self.scroll_offset = (self.scroll_offset + page_size).min(max_offset);
201 }
202
203 pub fn scroll_to_top(&mut self) {
205 self.scroll_offset = 0;
206 }
207
208 pub fn scroll_to_bottom(&mut self) {
210 self.scroll_offset = self.lines.len().saturating_sub(1);
211 }
212}
213
214impl Default for DiffView {
215 fn default() -> Self {
216 Self::new()
217 }
218}
219
220impl Widget for DiffView {
221 fn render(self, area: Rect, buf: &mut Buffer) {
222 let max_line = self
224 .lines
225 .iter()
226 .filter_map(|l| l.old_line.or(l.new_line))
227 .max();
228 let line_num_width = max_line.map_or(0, |n| n.to_string().len()) + 1;
229
230 let visible_count = area.height as usize;
232 let start_idx = self.scroll_offset;
233 let end_idx = (start_idx + visible_count).min(self.lines.len());
234
235 let lang = self.detect_language_from_diff();
237
238 for (i, line) in self.lines[start_idx..end_idx].iter().enumerate() {
239 let y = area.y + i as u16;
240 if y >= area.bottom() {
241 break;
242 }
243
244 let old_line_str = line.old_line.map(|n| n.to_string()).unwrap_or_default();
246 let new_line_str = line.new_line.map(|n| n.to_string()).unwrap_or_default();
247
248 let style = match line.line_type {
250 DiffType::Addition => Style::default().fg(Color::Green),
251 DiffType::Deletion => Style::default().fg(Color::Red),
252 DiffType::Context => Style::default().fg(Color::White),
253 DiffType::Header => Style::default().fg(Color::Yellow),
254 };
255
256 let line_num_text = if old_line_str.is_empty() && new_line_str.is_empty() {
258 format!("{:>line_num_width$} ", "")
259 } else if old_line_str.is_empty() {
260 format!("{:>line_num_width$} ", new_line_str)
261 } else {
262 format!("{:>line_num_width$} ", old_line_str)
263 };
264
265 let content_max_width = (area.width as usize).saturating_sub(line_num_width + 2);
267 let content = if line.content.len() > content_max_width {
268 format!(
269 "{}...",
270 &line.content[..content_max_width.saturating_sub(3)]
271 )
272 } else {
273 line.content.clone()
274 };
275
276 let line_num_spans = vec![Span::styled(
278 line_num_text,
279 Style::default().fg(Color::DarkGray),
280 )];
281 let line_num_line = Line::from(line_num_spans);
282 buf.set_line(area.x, y, &line_num_line, line_num_width as u16);
283
284 let content_start_x = area.x + line_num_width as u16;
286
287 let text_spans = if matches!(line.line_type, DiffType::Addition | DiffType::Deletion)
289 && !lang.is_empty()
290 && !line.content.is_empty()
291 {
292 let line_content = format!("{}\n", line.content);
294 match self.highlighter.highlight_to_spans(&line_content, lang) {
295 Ok(highlighted_lines) if !highlighted_lines.is_empty() => {
296 highlighted_lines[0].clone()
297 }
298 _ => vec![Span::styled(content, style)],
299 }
300 } else {
301 vec![Span::styled(content, style)]
302 };
303
304 let text_line = Line::from(text_spans);
305 buf.set_line(
306 content_start_x,
307 y,
308 &text_line,
309 area.width - line_num_width as u16,
310 );
311 }
312 }
313}
314
315impl Widget for &DiffView {
316 fn render(self, area: Rect, buf: &mut Buffer) {
317 self.clone().render(area, buf);
320 }
321}
322
323pub fn parse_diff(diff_text: &str) -> Vec<DiffLine> {
325 let mut lines = Vec::new();
326 let mut old_line: Option<usize> = None;
327 let mut new_line: Option<usize> = None;
328 let mut in_hunk = false;
329
330 for line in diff_text.lines() {
331 if line.starts_with("---") {
332 lines.push(DiffLine::new(DiffType::Header, line.to_string()));
334 in_hunk = false;
335 } else if line.starts_with("+++") {
336 lines.push(DiffLine::new(DiffType::Header, line.to_string()));
338 in_hunk = false;
339 } else if line.starts_with("@@") {
340 lines.push(DiffLine::new(DiffType::Header, line.to_string()));
342 in_hunk = true;
343
344 if let Some(hunk_part) = line.split("@@").nth(1) {
347 let parts: Vec<&str> = hunk_part.split_whitespace().collect();
348 for part in parts {
349 if part.starts_with('-') {
350 if let Some(line_num) = part.trim_start_matches('-').split(',').next() {
352 old_line = line_num.parse().ok();
353 }
354 } else if part.starts_with('+') {
355 if let Some(line_num) = part.trim_start_matches('+').split(',').next() {
357 new_line = line_num.parse().ok();
358 }
359 }
360 }
361 }
362 } else if in_hunk {
363 if let Some(stripped) = line.strip_prefix('+') {
365 let content = stripped.to_string();
367 let _line_num = new_line.map(|n| {
368 n + lines
369 .iter()
370 .filter(|l| l.line_type == DiffType::Addition)
371 .count()
372 });
373 lines.push(
374 DiffLine::new(DiffType::Addition, content).with_new_line(new_line.unwrap_or(0)),
375 );
376 new_line = new_line.map(|n| n + 1);
377 } else if let Some(stripped) = line.strip_prefix('-') {
378 let content = stripped.to_string();
380 lines.push(
381 DiffLine::new(DiffType::Deletion, content).with_old_line(old_line.unwrap_or(0)),
382 );
383 old_line = old_line.map(|n| n + 1);
384 } else if let Some(stripped) = line.strip_prefix(' ') {
385 let content = stripped.to_string();
387 lines.push(
388 DiffLine::new(DiffType::Context, content)
389 .with_old_line(old_line.unwrap_or(0))
390 .with_new_line(new_line.unwrap_or(0)),
391 );
392 old_line = old_line.map(|n| n + 1);
393 new_line = new_line.map(|n| n + 1);
394 } else {
395 lines.push(DiffLine::new(DiffType::Context, line.to_string()));
397 }
398 } else {
399 lines.push(DiffLine::new(DiffType::Header, line.to_string()));
401 }
402 }
403
404 lines
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn test_diff_view_new() {
413 let view = DiffView::new();
414 assert_eq!(view.len(), 0);
415 assert!(view.is_empty());
416 assert_eq!(view.scroll_offset(), 0);
417 }
418
419 #[test]
420 fn test_diff_view_default() {
421 let view = DiffView::default();
422 assert_eq!(view.len(), 0);
423 assert!(view.is_empty());
424 }
425
426 #[test]
427 fn test_parse_diff() {
428 let diff_text = r#"--- a/file.txt
429+++ b/file.txt
430@@ -1,3 +1,4 @@
431 line 1
432-deleted line
433+added line
434 line 2
435+new line"#;
436
437 let lines = parse_diff(diff_text);
438
439 assert_eq!(lines.len(), 8);
440
441 assert_eq!(lines[0].line_type, DiffType::Header);
443 assert!(lines[0].content.contains("---"));
444
445 let hunk_idx = lines
447 .iter()
448 .position(|l| l.line_type == DiffType::Header && l.content.starts_with("@@"));
449 assert!(hunk_idx.is_some());
450
451 let context_idx = lines
453 .iter()
454 .position(|l| l.line_type == DiffType::Context && l.content == "line 1");
455 assert!(context_idx.is_some());
456
457 let deletion_idx = lines
459 .iter()
460 .position(|l| l.line_type == DiffType::Deletion && l.content == "deleted line");
461 assert!(deletion_idx.is_some());
462
463 let addition_idx = lines
465 .iter()
466 .position(|l| l.line_type == DiffType::Addition && l.content == "added line");
467 assert!(addition_idx.is_some());
468
469 let new_addition_idx = lines
471 .iter()
472 .position(|l| l.line_type == DiffType::Addition && l.content == "new line");
473 assert!(new_addition_idx.is_some());
474 }
475
476 #[test]
477 fn test_diff_view_from_diff() {
478 let diff_text = "--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-context\n+new";
479 let view = DiffView::from_diff(diff_text);
480
481 assert_eq!(view.len(), 5);
482 assert!(!view.is_empty());
483 }
484
485 #[test]
486 fn test_diff_view_set_diff() {
487 let mut view = DiffView::new();
488 assert!(view.is_empty());
489
490 let diff_text = "--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-old\n+new";
491 view.set_diff(diff_text);
492
493 assert_eq!(view.len(), 5);
494 assert!(!view.is_empty());
495 assert_eq!(view.scroll_offset(), 0);
496 }
497
498 #[test]
499 fn test_diff_view_scroll() {
500 let mut view = DiffView::from_diff(
501 "--- a/file.txt\n+++ b/file.txt\n@@ -1,5 +1,5 @@\n line 1\n line 2\n line 3\n line 4\n line 5",
502 );
503
504 assert_eq!(view.scroll_offset(), 0);
505
506 view.scroll_down();
508 assert_eq!(view.scroll_offset(), 1);
509
510 view.scroll_down();
511 assert_eq!(view.scroll_offset(), 2);
512
513 view.scroll_up();
515 assert_eq!(view.scroll_offset(), 1);
516
517 view.scroll_up();
518 assert_eq!(view.scroll_offset(), 0);
519
520 view.scroll_up();
522 assert_eq!(view.scroll_offset(), 0);
523 }
524
525 #[test]
526 fn test_diff_view_page_scroll() {
527 let mut view = DiffView::from_diff(
528 "--- a/file.txt\n+++ b/file.txt\n@@ -1,20 +1,20 @@\n line 1\n line 2\n line 3\n line 4\n line 5\n line 6\n line 7\n line 8\n line 9\n line 10\n line 11\n line 12\n line 13\n line 14\n line 15\n line 16\n line 17\n line 18\n line 19\n line 20",
529 );
530
531 assert_eq!(view.scroll_offset(), 0);
532
533 view.page_down(10);
535 assert_eq!(view.scroll_offset(), 10);
536
537 view.page_down(10);
538 assert_eq!(view.scroll_offset(), 20); view.page_up(10);
542 assert_eq!(view.scroll_offset(), 10);
543
544 view.page_up(10);
545 assert_eq!(view.scroll_offset(), 0);
546
547 view.page_up(10);
549 assert_eq!(view.scroll_offset(), 0);
550 }
551
552 #[test]
553 fn test_diff_view_scroll_to_top_bottom() {
554 let mut view = DiffView::from_diff(
555 "--- a/file.txt\n+++ b/file.txt\n@@ -1,10 +1,10 @@\n line 1\n line 2\n line 3\n line 4\n line 5\n line 6\n line 7\n line 8\n line 9\n line 10",
556 );
557
558 view.scroll_down();
559 view.scroll_down();
560
561 assert_eq!(view.scroll_offset(), 2);
562
563 view.scroll_to_top();
564 assert_eq!(view.scroll_offset(), 0);
565
566 view.scroll_to_bottom();
567 assert_eq!(view.scroll_offset(), 12); }
569
570 #[test]
571 fn test_diff_view_large_file() {
572 let mut diff_text =
574 String::from("--- a/large.txt\n+++ b/large.txt\n@@ -1,10000 +1,10000 @@\n");
575
576 for i in 1..=10000 {
577 diff_text.push_str(&format!(" line {}\n", i));
578 }
579
580 let view = DiffView::from_diff(&diff_text);
581
582 assert_eq!(view.len(), 10003); assert!(!view.is_empty());
584
585 let mut view = DiffView::from_diff(&diff_text);
587 view.page_down(100);
588 assert_eq!(view.scroll_offset(), 100);
589
590 view.page_up(50);
591 assert_eq!(view.scroll_offset(), 50);
592
593 view.scroll_to_bottom();
594 assert_eq!(view.scroll_offset(), 10002);
595 }
596
597 #[test]
598 fn test_diff_line_new() {
599 let line = DiffLine::new(DiffType::Addition, "test content".to_string());
600 assert_eq!(line.line_type, DiffType::Addition);
601 assert_eq!(line.content, "test content");
602 assert_eq!(line.old_line, None);
603 assert_eq!(line.new_line, None);
604 }
605
606 #[test]
607 fn test_diff_line_with_line_numbers() {
608 let line = DiffLine::new(DiffType::Addition, "test".to_string())
609 .with_old_line(10)
610 .with_new_line(15);
611
612 assert_eq!(line.old_line, Some(10));
613 assert_eq!(line.new_line, Some(15));
614 }
615
616 #[test]
617 fn test_parse_empty_diff() {
618 let lines = parse_diff("");
619 assert!(lines.is_empty());
620 }
621
622 #[test]
623 fn test_parse_diff_only_headers() {
624 let diff_text = "--- a/file.txt\n+++ b/file.txt";
625 let lines = parse_diff(diff_text);
626
627 assert_eq!(lines.len(), 2);
628 assert_eq!(lines[0].line_type, DiffType::Header);
629 assert_eq!(lines[1].line_type, DiffType::Header);
630 }
631
632 #[test]
633 fn test_diff_view_render() {
634 let view = DiffView::from_diff(
635 "--- a/file.txt\n+++ b/file.txt\n@@ -1,3 +1,3 @@\n line 1\n-old\n+new\n line 3",
636 );
637
638 assert_eq!(view.len(), 7);
641 }
642}