perspt_tui/
diff_viewer.rs

1//! Diff Viewer Component
2//!
3//! Rich diff display with syntax highlighting and line numbers.
4
5use crate::theme::Theme;
6use ratatui::{
7    layout::{Constraint, Direction, Layout, Rect},
8    style::{Color, Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Tabs},
11    Frame,
12};
13use similar::{ChangeTag, TextDiff};
14
15/// Display mode for diffs
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum DiffViewMode {
18    #[default]
19    Unified,
20    SideBySide,
21}
22
23/// A single diff hunk
24#[derive(Debug, Clone)]
25pub struct DiffHunk {
26    /// Original file path
27    pub file_path: String,
28    /// File extension (for syntax highlighting)
29    pub extension: Option<String>,
30    /// Lines with change type
31    pub lines: Vec<DiffLine>,
32    /// Original line number start
33    pub old_start: usize,
34    /// New line number start
35    pub new_start: usize,
36}
37
38/// A diff line with its type
39#[derive(Debug, Clone)]
40pub struct DiffLine {
41    /// Line content
42    pub content: String,
43    /// Line type
44    pub line_type: DiffLineType,
45    /// Old line number (if applicable)
46    pub old_line_number: Option<usize>,
47    /// New line number (if applicable)
48    pub new_line_number: Option<usize>,
49}
50
51/// Type of diff line
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum DiffLineType {
54    /// Unchanged context line
55    Context,
56    /// Added line
57    Added,
58    /// Removed line
59    Removed,
60    /// Header line (file path, etc.)
61    Header,
62    /// Hunk header (@@...@@)
63    HunkHeader,
64}
65
66impl DiffLine {
67    pub fn new(content: &str, line_type: DiffLineType) -> Self {
68        Self {
69            content: content.to_string(),
70            line_type,
71            old_line_number: None,
72            new_line_number: None,
73        }
74    }
75
76    pub fn with_line_numbers(
77        content: &str,
78        line_type: DiffLineType,
79        old: Option<usize>,
80        new: Option<usize>,
81    ) -> Self {
82        Self {
83            content: content.to_string(),
84            line_type,
85            old_line_number: old,
86            new_line_number: new,
87        }
88    }
89}
90
91/// Enhanced diff viewer with syntax highlighting
92pub struct DiffViewer {
93    /// Diff hunks to display
94    pub hunks: Vec<DiffHunk>,
95    /// Current scroll offset
96    pub scroll: usize,
97    /// Currently selected hunk index
98    pub selected_hunk: usize,
99    /// View mode
100    pub view_mode: DiffViewMode,
101    /// Theme for styling
102    theme: Theme,
103    /// Total line count (cached for scrolling)
104    total_lines: usize,
105}
106
107impl Default for DiffViewer {
108    fn default() -> Self {
109        Self {
110            hunks: Vec::new(),
111            scroll: 0,
112            selected_hunk: 0,
113            view_mode: DiffViewMode::Unified,
114            theme: Theme::default(),
115            total_lines: 0,
116        }
117    }
118}
119
120impl DiffViewer {
121    /// Create a new diff viewer
122    pub fn new() -> Self {
123        Self::default()
124    }
125
126    /// Compute diff between two strings using `similar`
127    pub fn compute_diff(&mut self, file_path: &str, old_content: &str, new_content: &str) {
128        self.hunks.clear();
129
130        let diff = TextDiff::from_lines(old_content, new_content);
131        let extension = file_path.rsplit('.').next().map(String::from);
132
133        let mut current_hunk = DiffHunk {
134            file_path: file_path.to_string(),
135            extension: extension.clone(),
136            lines: vec![DiffLine::new(
137                &format!("diff --git a/{} b/{}", file_path, file_path),
138                DiffLineType::Header,
139            )],
140            old_start: 1,
141            new_start: 1,
142        };
143
144        let mut old_line = 1usize;
145        let mut new_line = 1usize;
146
147        for change in diff.iter_all_changes() {
148            let (line_type, old_num, new_num) = match change.tag() {
149                ChangeTag::Delete => {
150                    let num = old_line;
151                    old_line += 1;
152                    (DiffLineType::Removed, Some(num), None)
153                }
154                ChangeTag::Insert => {
155                    let num = new_line;
156                    new_line += 1;
157                    (DiffLineType::Added, None, Some(num))
158                }
159                ChangeTag::Equal => {
160                    let o = old_line;
161                    let n = new_line;
162                    old_line += 1;
163                    new_line += 1;
164                    (DiffLineType::Context, Some(o), Some(n))
165                }
166            };
167
168            // Remove trailing newline for display
169            let content = change.value().trim_end_matches('\n');
170            current_hunk.lines.push(DiffLine::with_line_numbers(
171                content, line_type, old_num, new_num,
172            ));
173        }
174
175        if !current_hunk.lines.is_empty() {
176            self.hunks.push(current_hunk);
177        }
178
179        self.update_total_lines();
180    }
181
182    /// Parse a unified diff string
183    pub fn parse_diff(&mut self, diff_text: &str) {
184        self.hunks.clear();
185        let mut current_hunk: Option<DiffHunk> = None;
186        let mut old_line = 1usize;
187        let mut new_line = 1usize;
188
189        for line in diff_text.lines() {
190            if line.starts_with("diff --git") {
191                if let Some(hunk) = current_hunk.take() {
192                    self.hunks.push(hunk);
193                }
194                let file_path = line.split(" b/").nth(1).unwrap_or("unknown").to_string();
195                let extension = file_path.rsplit('.').next().map(String::from);
196                current_hunk = Some(DiffHunk {
197                    file_path,
198                    extension,
199                    lines: vec![DiffLine::new(line, DiffLineType::Header)],
200                    old_start: 1,
201                    new_start: 1,
202                });
203                old_line = 1;
204                new_line = 1;
205            } else if line.starts_with("---") || line.starts_with("+++") {
206                if let Some(ref mut hunk) = current_hunk {
207                    hunk.lines.push(DiffLine::new(line, DiffLineType::Header));
208                }
209            } else if line.starts_with("@@") {
210                // Parse hunk header to get line numbers
211                if let Some(ref mut hunk) = current_hunk {
212                    hunk.lines
213                        .push(DiffLine::new(line, DiffLineType::HunkHeader));
214                    // Parse @@ -old_start,old_count +new_start,new_count @@
215                    if let Some(nums) = parse_hunk_header(line) {
216                        old_line = nums.0;
217                        new_line = nums.2;
218                        hunk.old_start = nums.0;
219                        hunk.new_start = nums.2;
220                    }
221                }
222            } else if let Some(ref mut hunk) = current_hunk {
223                let (line_type, old_num, new_num) = if line.starts_with('+') {
224                    let n = new_line;
225                    new_line += 1;
226                    (DiffLineType::Added, None, Some(n))
227                } else if line.starts_with('-') {
228                    let o = old_line;
229                    old_line += 1;
230                    (DiffLineType::Removed, Some(o), None)
231                } else {
232                    let o = old_line;
233                    let n = new_line;
234                    old_line += 1;
235                    new_line += 1;
236                    (DiffLineType::Context, Some(o), Some(n))
237                };
238
239                // Remove the +/- or space prefix for display
240                let content = if line.len() > 1
241                    && (line.starts_with('+') || line.starts_with('-') || line.starts_with(' '))
242                {
243                    &line[1..]
244                } else {
245                    line
246                };
247
248                hunk.lines.push(DiffLine::with_line_numbers(
249                    content, line_type, old_num, new_num,
250                ));
251            }
252        }
253
254        if let Some(hunk) = current_hunk {
255            self.hunks.push(hunk);
256        }
257
258        self.update_total_lines();
259    }
260
261    /// Clear all diffs
262    pub fn clear(&mut self) {
263        self.hunks.clear();
264        self.scroll = 0;
265        self.selected_hunk = 0;
266        self.total_lines = 0;
267    }
268
269    fn update_total_lines(&mut self) {
270        self.total_lines = self.hunks.iter().map(|h| h.lines.len()).sum();
271    }
272
273    /// Toggle view mode
274    pub fn toggle_view_mode(&mut self) {
275        self.view_mode = match self.view_mode {
276            DiffViewMode::Unified => DiffViewMode::SideBySide,
277            DiffViewMode::SideBySide => DiffViewMode::Unified,
278        };
279    }
280
281    /// Scroll up
282    pub fn scroll_up(&mut self) {
283        self.scroll = self.scroll.saturating_sub(1);
284    }
285
286    /// Scroll down
287    pub fn scroll_down(&mut self) {
288        self.scroll = self.scroll.saturating_add(1);
289    }
290
291    /// Page up
292    pub fn page_up(&mut self, lines: usize) {
293        self.scroll = self.scroll.saturating_sub(lines);
294    }
295
296    /// Page down
297    pub fn page_down(&mut self, lines: usize) {
298        self.scroll = self.scroll.saturating_add(lines);
299    }
300
301    /// Next hunk
302    pub fn next_hunk(&mut self) {
303        if self.selected_hunk < self.hunks.len().saturating_sub(1) {
304            self.selected_hunk += 1;
305            // Scroll to show the hunk
306            let mut line_offset = 0;
307            for i in 0..self.selected_hunk {
308                line_offset += self.hunks[i].lines.len();
309            }
310            self.scroll = line_offset;
311        }
312    }
313
314    /// Previous hunk
315    pub fn prev_hunk(&mut self) {
316        if self.selected_hunk > 0 {
317            self.selected_hunk -= 1;
318            let mut line_offset = 0;
319            for i in 0..self.selected_hunk {
320                line_offset += self.hunks[i].lines.len();
321            }
322            self.scroll = line_offset;
323        }
324    }
325
326    /// Render the diff viewer
327    pub fn render(&self, frame: &mut Frame, area: Rect) {
328        let chunks = Layout::default()
329            .direction(Direction::Vertical)
330            .constraints([Constraint::Length(3), Constraint::Min(5)])
331            .split(area);
332
333        // Tabs for view mode
334        let tab_titles = vec!["Unified", "Side-by-Side"];
335        let tabs = Tabs::new(tab_titles)
336            .block(
337                Block::default()
338                    .borders(Borders::ALL)
339                    .title("View Mode")
340                    .border_style(self.theme.border),
341            )
342            .select(match self.view_mode {
343                DiffViewMode::Unified => 0,
344                DiffViewMode::SideBySide => 1,
345            })
346            .style(Style::default().fg(Color::White))
347            .highlight_style(self.theme.highlight);
348        frame.render_widget(tabs, chunks[0]);
349
350        // Diff content
351        match self.view_mode {
352            DiffViewMode::Unified => self.render_unified(frame, chunks[1]),
353            DiffViewMode::SideBySide => self.render_side_by_side(frame, chunks[1]),
354        }
355    }
356
357    fn render_unified(&self, frame: &mut Frame, area: Rect) {
358        let lines: Vec<Line> = self
359            .hunks
360            .iter()
361            .enumerate()
362            .flat_map(|(hunk_idx, hunk)| {
363                hunk.lines.iter().map(move |line| {
364                    let (fg_color, bg_color, prefix) = match line.line_type {
365                        DiffLineType::Added => {
366                            (Color::Rgb(200, 255, 200), Some(Color::Rgb(30, 50, 30)), "+")
367                        }
368                        DiffLineType::Removed => {
369                            (Color::Rgb(255, 200, 200), Some(Color::Rgb(50, 30, 30)), "-")
370                        }
371                        DiffLineType::Header => (Color::Rgb(129, 212, 250), None, " "),
372                        DiffLineType::HunkHeader => {
373                            (Color::Rgb(186, 104, 200), Some(Color::Rgb(40, 30, 50)), " ")
374                        }
375                        DiffLineType::Context => (Color::Rgb(180, 180, 180), None, " "),
376                    };
377
378                    // Build line number display
379                    let line_nums = match (line.old_line_number, line.new_line_number) {
380                        (Some(o), Some(n)) => format!("{:>4} {:>4} ", o, n),
381                        (Some(o), None) => format!("{:>4}      ", o),
382                        (None, Some(n)) => format!("     {:>4} ", n),
383                        (None, None) => "          ".to_string(),
384                    };
385
386                    let mut spans = vec![
387                        Span::styled(line_nums, Style::default().fg(Color::Rgb(100, 100, 100))),
388                        Span::styled(format!("{} ", prefix), Style::default().fg(fg_color)),
389                    ];
390
391                    // Highlight selected hunk
392                    let content_style = if hunk_idx == self.selected_hunk {
393                        Style::default().fg(fg_color).add_modifier(Modifier::BOLD)
394                    } else {
395                        Style::default().fg(fg_color)
396                    };
397
398                    let content_style = if let Some(bg) = bg_color {
399                        content_style.bg(bg)
400                    } else {
401                        content_style
402                    };
403
404                    spans.push(Span::styled(&line.content, content_style));
405
406                    Line::from(spans)
407                })
408            })
409            .collect();
410
411        let visible_lines = area.height.saturating_sub(2) as usize;
412        let max_scroll = self.total_lines.saturating_sub(visible_lines);
413        let scroll = self.scroll.min(max_scroll);
414
415        let stats = self.compute_stats();
416        let title = format!(
417            "📝 Diff: {} files, +{} -{} ({} hunks)",
418            self.hunks.len(),
419            stats.additions,
420            stats.deletions,
421            self.hunks.len()
422        );
423
424        let para = Paragraph::new(lines)
425            .block(
426                Block::default()
427                    .title(title)
428                    .borders(Borders::ALL)
429                    .border_style(self.theme.border),
430            )
431            .scroll((scroll as u16, 0));
432
433        frame.render_widget(para, area);
434
435        // Scrollbar
436        let scrollbar = Scrollbar::default()
437            .orientation(ScrollbarOrientation::VerticalRight)
438            .begin_symbol(Some("↑"))
439            .end_symbol(Some("↓"));
440        let mut scrollbar_state = ScrollbarState::new(self.total_lines).position(scroll);
441        frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
442    }
443
444    fn render_side_by_side(&self, frame: &mut Frame, area: Rect) {
445        // Split into two columns
446        let columns = Layout::default()
447            .direction(Direction::Horizontal)
448            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
449            .split(area);
450
451        // Left side (old)
452        let old_lines: Vec<Line> = self
453            .hunks
454            .iter()
455            .flat_map(|hunk| {
456                hunk.lines.iter().filter_map(|line| {
457                    match line.line_type {
458                        DiffLineType::Removed | DiffLineType::Context => {
459                            let num = line
460                                .old_line_number
461                                .map(|n| format!("{:>4} ", n))
462                                .unwrap_or_else(|| "     ".to_string());
463                            let style = match line.line_type {
464                                DiffLineType::Removed => Style::default()
465                                    .fg(Color::Rgb(255, 200, 200))
466                                    .bg(Color::Rgb(50, 30, 30)),
467                                _ => Style::default().fg(Color::Rgb(180, 180, 180)),
468                            };
469                            Some(Line::from(vec![
470                                Span::styled(num, Style::default().fg(Color::Rgb(100, 100, 100))),
471                                Span::styled(&line.content, style),
472                            ]))
473                        }
474                        DiffLineType::Added => Some(Line::from("")), // Empty placeholder
475                        _ => None,
476                    }
477                })
478            })
479            .collect();
480
481        // Right side (new)
482        let new_lines: Vec<Line> = self
483            .hunks
484            .iter()
485            .flat_map(|hunk| {
486                hunk.lines.iter().filter_map(|line| {
487                    match line.line_type {
488                        DiffLineType::Added | DiffLineType::Context => {
489                            let num = line
490                                .new_line_number
491                                .map(|n| format!("{:>4} ", n))
492                                .unwrap_or_else(|| "     ".to_string());
493                            let style = match line.line_type {
494                                DiffLineType::Added => Style::default()
495                                    .fg(Color::Rgb(200, 255, 200))
496                                    .bg(Color::Rgb(30, 50, 30)),
497                                _ => Style::default().fg(Color::Rgb(180, 180, 180)),
498                            };
499                            Some(Line::from(vec![
500                                Span::styled(num, Style::default().fg(Color::Rgb(100, 100, 100))),
501                                Span::styled(&line.content, style),
502                            ]))
503                        }
504                        DiffLineType::Removed => Some(Line::from("")), // Empty placeholder
505                        _ => None,
506                    }
507                })
508            })
509            .collect();
510
511        let visible = area.height.saturating_sub(2) as usize;
512        let scroll = self.scroll.min(old_lines.len().saturating_sub(visible));
513
514        let old_para = Paragraph::new(old_lines)
515            .block(Block::default().title("Old").borders(Borders::ALL))
516            .scroll((scroll as u16, 0));
517        frame.render_widget(old_para, columns[0]);
518
519        let new_para = Paragraph::new(new_lines)
520            .block(Block::default().title("New").borders(Borders::ALL))
521            .scroll((scroll as u16, 0));
522        frame.render_widget(new_para, columns[1]);
523    }
524
525    /// Compute diff statistics
526    fn compute_stats(&self) -> DiffStats {
527        let mut stats = DiffStats::default();
528        for hunk in &self.hunks {
529            for line in &hunk.lines {
530                match line.line_type {
531                    DiffLineType::Added => stats.additions += 1,
532                    DiffLineType::Removed => stats.deletions += 1,
533                    _ => {}
534                }
535            }
536        }
537        stats
538    }
539}
540
541/// Parse hunk header like "@@ -1,5 +1,7 @@"
542fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> {
543    let parts: Vec<&str> = line.split_whitespace().collect();
544    if parts.len() < 3 {
545        return None;
546    }
547
548    let old_range = parts.get(1)?;
549    let new_range = parts.get(2)?;
550
551    let parse_range = |s: &str| -> Option<(usize, usize)> {
552        let s = s.trim_start_matches(['-', '+'].as_ref());
553        let parts: Vec<&str> = s.split(',').collect();
554        let start = parts.first()?.parse().ok()?;
555        let count = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
556        Some((start, count))
557    };
558
559    let (old_start, old_count) = parse_range(old_range)?;
560    let (new_start, new_count) = parse_range(new_range)?;
561
562    Some((old_start, old_count, new_start, new_count))
563}
564
565#[derive(Default)]
566struct DiffStats {
567    additions: usize,
568    deletions: usize,
569}
570
571#[cfg(test)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn test_compute_diff() {
577        let mut viewer = DiffViewer::new();
578        viewer.compute_diff(
579            "test.rs",
580            "line1\nline2\nline3\n",
581            "line1\nmodified\nline3\nnew line\n",
582        );
583
584        assert_eq!(viewer.hunks.len(), 1);
585        // Should have some added and removed lines
586        let stats = viewer.compute_stats();
587        assert!(stats.additions > 0);
588        assert!(stats.deletions > 0);
589    }
590
591    #[test]
592    fn test_parse_hunk_header() {
593        let result = parse_hunk_header("@@ -1,5 +1,7 @@");
594        assert_eq!(result, Some((1, 5, 1, 7)));
595    }
596}