Skip to main content

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