Skip to main content

limit_tui/components/
diff.rs

1// Diff View component for displaying unified diffs
2
3use 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/// Type of diff line
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum DiffType {
16    /// Added line (+)
17    Addition,
18    /// Removed line (-)
19    Deletion,
20    /// Context line (no prefix)
21    Context,
22    /// Header line (@@ ... @@)
23    Header,
24}
25
26/// A single line in a diff view
27#[derive(Debug, Clone)]
28pub struct DiffLine {
29    /// Type of diff line
30    pub line_type: DiffType,
31    /// Content of the line (without prefix)
32    pub content: String,
33    /// Line number in the old file (if applicable)
34    pub old_line: Option<usize>,
35    /// Line number in the new file (if applicable)
36    pub new_line: Option<usize>,
37}
38
39impl DiffLine {
40    /// Create a new diff line
41    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    /// Set old line number
51    pub fn with_old_line(mut self, line: usize) -> Self {
52        self.old_line = Some(line);
53        self
54    }
55
56    /// Set new line number
57    pub fn with_new_line(mut self, line: usize) -> Self {
58        self.new_line = Some(line);
59        self
60    }
61}
62
63/// Diff view component for displaying unified diffs
64#[derive(Clone)]
65pub struct DiffView {
66    /// Parsed diff lines
67    lines: Vec<DiffLine>,
68    /// Current scroll offset (line number)
69    scroll_offset: usize,
70    /// Syntax highlighter for code content
71    highlighter: SyntaxHighlighter,
72}
73
74impl DiffView {
75    /// Extract language from diff headers
76    fn detect_language_from_diff(&self) -> &str {
77        // Look for file extensions in the diff headers
78        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        // Default to plain text
134        ""
135    }
136    /// Create a new empty diff view
137    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    /// Create a new diff view from a unified diff string
147    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    /// Set the diff content
157    /// Set the diff content
158    pub fn set_diff(&mut self, diff_text: &str) {
159        self.lines = parse_diff(diff_text);
160        self.scroll_offset = 0;
161    }
162
163    /// Get the number of lines in the diff
164    pub fn len(&self) -> usize {
165        self.lines.len()
166    }
167
168    /// Check if the diff is empty
169    pub fn is_empty(&self) -> bool {
170        self.lines.is_empty()
171    }
172
173    /// Get current scroll offset
174    pub fn scroll_offset(&self) -> usize {
175        self.scroll_offset
176    }
177
178    /// Scroll up by one line
179    pub fn scroll_up(&mut self) {
180        if self.scroll_offset > 0 {
181            self.scroll_offset -= 1;
182        }
183    }
184
185    /// Scroll down by one line
186    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    /// Scroll up by one page
193    pub fn page_up(&mut self, page_size: usize) {
194        self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
195    }
196
197    /// Scroll down by one page
198    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    /// Scroll to the top
204    pub fn scroll_to_top(&mut self) {
205        self.scroll_offset = 0;
206    }
207
208    /// Scroll to the bottom
209    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        // Calculate line numbers column width
223        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        // Only render visible lines (virtualization)
231        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        // Detect language from diff for syntax highlighting
236        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            // Render line numbers
245            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            // Style based on diff type
249            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            // Build line numbers display
257            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            // Truncate line content if too long
266            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            // Render line numbers with dim style
277            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            // Render diff line with optional syntax highlighting
285            let content_start_x = area.x + line_num_width as u16;
286
287            // Apply syntax highlighting for code lines (non-header, non-context lines with code)
288            let text_spans = if matches!(line.line_type, DiffType::Addition | DiffType::Deletion)
289                && !lang.is_empty()
290                && !line.content.is_empty()
291            {
292                // Try syntax highlighting for this line
293                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        // Delegate to the owned implementation by cloning
318        // This allows rendering by reference
319        self.clone().render(area, buf);
320    }
321}
322
323/// Parse unified diff format into DiffLine structs
324pub 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            // Old file header
333            lines.push(DiffLine::new(DiffType::Header, line.to_string()));
334            in_hunk = false;
335        } else if line.starts_with("+++") {
336            // New file header
337            lines.push(DiffLine::new(DiffType::Header, line.to_string()));
338            in_hunk = false;
339        } else if line.starts_with("@@") {
340            // Hunk header - parse line numbers
341            lines.push(DiffLine::new(DiffType::Header, line.to_string()));
342            in_hunk = true;
343
344            // Parse hunk header to get starting line numbers
345            // Format: @@ -old_start,old_count +new_start,new_count @@
346            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                        // Old file start line
351                        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                        // New file start line
356                        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            // Hunk content
364            if let Some(stripped) = line.strip_prefix('+') {
365                // Added line
366                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                // Removed line
379                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                // Context line
386                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                // Other line (treat as context)
396                lines.push(DiffLine::new(DiffType::Context, line.to_string()));
397            }
398        } else {
399            // Outside hunk (header lines)
400            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        // Check first header
442        assert_eq!(lines[0].line_type, DiffType::Header);
443        assert!(lines[0].content.contains("---"));
444
445        // Check hunk header
446        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        // Check context line
452        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        // Check deletion
458        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        // Check addition
464        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        // Check new line addition
470        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        // Scroll down
507        view.scroll_down();
508        assert_eq!(view.scroll_offset(), 1);
509
510        view.scroll_down();
511        assert_eq!(view.scroll_offset(), 2);
512
513        // Scroll up
514        view.scroll_up();
515        assert_eq!(view.scroll_offset(), 1);
516
517        view.scroll_up();
518        assert_eq!(view.scroll_offset(), 0);
519
520        // Can't scroll past top
521        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        // Page down
534        view.page_down(10);
535        assert_eq!(view.scroll_offset(), 10);
536
537        view.page_down(10);
538        assert_eq!(view.scroll_offset(), 20); // Can.t go past last line
539
540        // Page up
541        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        // Can't go past top
548        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); // Last line index
568    }
569
570    #[test]
571    fn test_diff_view_large_file() {
572        // Create a large diff with 10K+ lines
573        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); // Headers + 10000 context lines
583        assert!(!view.is_empty());
584
585        // Test scrolling works with large file
586        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        // This test just verifies that the render method compiles
639        // Actual rendering is tested visually via the example
640        assert_eq!(view.len(), 7);
641    }
642}