Skip to main content

ratatui_interact/components/
diff_viewer.rs

1//! Diff viewer widget
2//!
3//! A scrollable diff viewer with unified and side-by-side modes, syntax highlighting,
4//! search functionality, and hunk navigation.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ratatui_interact::components::{DiffViewer, DiffViewerState, DiffData};
10//! use ratatui::layout::Rect;
11//!
12//! // Parse a unified diff
13//! let diff_text = r#"
14//! --- a/file.txt
15//! +++ b/file.txt
16//! @@ -1,3 +1,4 @@
17//!  context line
18//! -removed line
19//! +added line
20//! +another added line
21//!  more context
22//! "#;
23//!
24//! let diff = DiffData::from_unified_diff(diff_text);
25//! let mut state = DiffViewerState::new(diff);
26//!
27//! // Create viewer
28//! let viewer = DiffViewer::new(&state)
29//!     .title("Changes");
30//! ```
31
32use ratatui::{
33    buffer::Buffer,
34    layout::{Constraint, Direction, Layout, Rect},
35    style::{Color, Modifier, Style},
36    text::{Line, Span},
37    widgets::{
38        Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget,
39        Widget,
40    },
41};
42
43use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
44
45use super::log_viewer::SearchState;
46
47// ============================================================================
48// Enums
49// ============================================================================
50
51/// View mode for displaying diffs
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum DiffViewMode {
54    /// Side-by-side view showing old and new files in parallel columns
55    SideBySide,
56    /// Unified view with + and - prefixes (default)
57    #[default]
58    Unified,
59}
60
61/// Type of diff line
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum DiffLineType {
64    /// Context line (unchanged)
65    Context,
66    /// Added line
67    Addition,
68    /// Removed line
69    Deletion,
70    /// Hunk header (@@ ... @@)
71    HunkHeader,
72}
73
74/// Actions that can be triggered by diff viewer interactions
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum DiffViewerAction {
77    /// Scroll to a specific line
78    ScrollToLine(usize),
79    /// Jump to a specific hunk by index
80    JumpToHunk(usize),
81    /// Toggle between side-by-side and unified modes
82    ToggleViewMode,
83}
84
85// ============================================================================
86// Data Structures
87// ============================================================================
88
89/// A single line in a diff
90#[derive(Debug, Clone)]
91pub struct DiffLine {
92    /// Type of the line (context, addition, deletion, header)
93    pub line_type: DiffLineType,
94    /// Content of the line (without the +/- prefix)
95    pub content: String,
96    /// Line number in the old file (None for additions/headers)
97    pub old_line_num: Option<usize>,
98    /// Line number in the new file (None for deletions/headers)
99    pub new_line_num: Option<usize>,
100    /// Character ranges for inline changes (start, end) within the line
101    pub inline_changes: Vec<(usize, usize)>,
102}
103
104impl DiffLine {
105    /// Create a new diff line
106    pub fn new(line_type: DiffLineType, content: String) -> Self {
107        Self {
108            line_type,
109            content,
110            old_line_num: None,
111            new_line_num: None,
112            inline_changes: Vec::new(),
113        }
114    }
115
116    /// Create a context line
117    pub fn context(content: String, old_num: usize, new_num: usize) -> Self {
118        Self {
119            line_type: DiffLineType::Context,
120            content,
121            old_line_num: Some(old_num),
122            new_line_num: Some(new_num),
123            inline_changes: Vec::new(),
124        }
125    }
126
127    /// Create an addition line
128    pub fn addition(content: String, new_num: usize) -> Self {
129        Self {
130            line_type: DiffLineType::Addition,
131            content,
132            old_line_num: None,
133            new_line_num: Some(new_num),
134            inline_changes: Vec::new(),
135        }
136    }
137
138    /// Create a deletion line
139    pub fn deletion(content: String, old_num: usize) -> Self {
140        Self {
141            line_type: DiffLineType::Deletion,
142            content,
143            old_line_num: Some(old_num),
144            new_line_num: None,
145            inline_changes: Vec::new(),
146        }
147    }
148
149    /// Create a hunk header line
150    pub fn hunk_header(content: String) -> Self {
151        Self {
152            line_type: DiffLineType::HunkHeader,
153            content,
154            old_line_num: None,
155            new_line_num: None,
156            inline_changes: Vec::new(),
157        }
158    }
159
160    /// Set inline changes for highlighting
161    pub fn with_inline_changes(mut self, changes: Vec<(usize, usize)>) -> Self {
162        self.inline_changes = changes;
163        self
164    }
165}
166
167/// A hunk in a diff (a contiguous block of changes)
168#[derive(Debug, Clone)]
169pub struct DiffHunk {
170    /// The hunk header string (e.g., "@@ -1,3 +1,4 @@")
171    pub header: String,
172    /// Starting line number in the old file
173    pub old_start: usize,
174    /// Number of lines from the old file
175    pub old_count: usize,
176    /// Starting line number in the new file
177    pub new_start: usize,
178    /// Number of lines in the new file
179    pub new_count: usize,
180    /// Lines in this hunk
181    pub lines: Vec<DiffLine>,
182}
183
184impl DiffHunk {
185    /// Create a new hunk
186    pub fn new(header: String, old_start: usize, old_count: usize, new_start: usize, new_count: usize) -> Self {
187        Self {
188            header,
189            old_start,
190            old_count,
191            new_start,
192            new_count,
193            lines: Vec::new(),
194        }
195    }
196
197    /// Add a line to the hunk
198    pub fn add_line(&mut self, line: DiffLine) {
199        self.lines.push(line);
200    }
201
202    /// Get the number of additions in this hunk
203    pub fn addition_count(&self) -> usize {
204        self.lines.iter().filter(|l| l.line_type == DiffLineType::Addition).count()
205    }
206
207    /// Get the number of deletions in this hunk
208    pub fn deletion_count(&self) -> usize {
209        self.lines.iter().filter(|l| l.line_type == DiffLineType::Deletion).count()
210    }
211}
212
213/// Complete diff data for one or more files
214#[derive(Debug, Clone, Default)]
215pub struct DiffData {
216    /// Path to the old file
217    pub old_path: Option<String>,
218    /// Path to the new file
219    pub new_path: Option<String>,
220    /// Hunks in the diff
221    pub hunks: Vec<DiffHunk>,
222}
223
224impl DiffData {
225    /// Create empty diff data
226    pub fn empty() -> Self {
227        Self::default()
228    }
229
230    /// Create diff data with paths
231    pub fn new(old_path: Option<String>, new_path: Option<String>) -> Self {
232        Self {
233            old_path,
234            new_path,
235            hunks: Vec::new(),
236        }
237    }
238
239    /// Parse a unified diff text into DiffData
240    pub fn from_unified_diff(text: &str) -> Self {
241        let mut diff = DiffData::empty();
242        let mut current_hunk: Option<DiffHunk> = None;
243        let mut old_line_num: usize = 0;
244        let mut new_line_num: usize = 0;
245
246        for line in text.lines() {
247            // File headers
248            if let Some(path) = line.strip_prefix("--- ") {
249                diff.old_path = Some(path.trim_start_matches("a/").to_string());
250                continue;
251            }
252            if let Some(path) = line.strip_prefix("+++ ") {
253                diff.new_path = Some(path.trim_start_matches("b/").to_string());
254                continue;
255            }
256
257            // Hunk header
258            if line.starts_with("@@") {
259                // Save previous hunk if any
260                if let Some(hunk) = current_hunk.take() {
261                    diff.hunks.push(hunk);
262                }
263
264                // Parse @@ -old_start,old_count +new_start,new_count @@
265                if let Some((old_start, old_count, new_start, new_count)) = parse_hunk_header(line) {
266                    current_hunk = Some(DiffHunk::new(
267                        line.to_string(),
268                        old_start,
269                        old_count,
270                        new_start,
271                        new_count,
272                    ));
273                    old_line_num = old_start;
274                    new_line_num = new_start;
275                }
276                continue;
277            }
278
279            // Diff content lines
280            if let Some(hunk) = current_hunk.as_mut() {
281                if let Some(content) = line.strip_prefix('+') {
282                    // Addition
283                    hunk.add_line(DiffLine::addition(content.to_string(), new_line_num));
284                    new_line_num += 1;
285                } else if let Some(content) = line.strip_prefix('-') {
286                    // Deletion
287                    hunk.add_line(DiffLine::deletion(content.to_string(), old_line_num));
288                    old_line_num += 1;
289                } else if let Some(content) = line.strip_prefix(' ') {
290                    // Context
291                    hunk.add_line(DiffLine::context(content.to_string(), old_line_num, new_line_num));
292                    old_line_num += 1;
293                    new_line_num += 1;
294                } else if line.is_empty() || line == "\\ No newline at end of file" {
295                    // Empty context line or no-newline marker
296                    if line.is_empty() {
297                        hunk.add_line(DiffLine::context(String::new(), old_line_num, new_line_num));
298                        old_line_num += 1;
299                        new_line_num += 1;
300                    }
301                }
302            }
303        }
304
305        // Don't forget the last hunk
306        if let Some(hunk) = current_hunk {
307            diff.hunks.push(hunk);
308        }
309
310        diff
311    }
312
313    /// Get total number of additions across all hunks
314    pub fn total_additions(&self) -> usize {
315        self.hunks.iter().map(|h| h.addition_count()).sum()
316    }
317
318    /// Get total number of deletions across all hunks
319    pub fn total_deletions(&self) -> usize {
320        self.hunks.iter().map(|h| h.deletion_count()).sum()
321    }
322
323    /// Get all lines flattened (for display purposes)
324    pub fn all_lines(&self) -> Vec<&DiffLine> {
325        let mut lines = Vec::new();
326        for hunk in &self.hunks {
327            for line in &hunk.lines {
328                lines.push(line);
329            }
330        }
331        lines
332    }
333
334    /// Check if the diff is empty
335    pub fn is_empty(&self) -> bool {
336        self.hunks.is_empty()
337    }
338}
339
340/// Parse a hunk header line like "@@ -1,3 +1,4 @@" or "@@ -1 +1 @@"
341fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> {
342    // Remove @@ markers and any trailing context
343    let content = line.trim_start_matches("@@ ").trim_end();
344    let end_marker_pos = content.find(" @@")?;
345    let ranges = &content[..end_marker_pos];
346
347    let mut parts = ranges.split_whitespace();
348    let old_range = parts.next()?.strip_prefix('-')?;
349    let new_range = parts.next()?.strip_prefix('+')?;
350
351    let (old_start, old_count) = parse_range(old_range);
352    let (new_start, new_count) = parse_range(new_range);
353
354    Some((old_start, old_count, new_start, new_count))
355}
356
357/// Parse a range like "1,3" or "1" into (start, count)
358fn parse_range(range: &str) -> (usize, usize) {
359    if let Some((start, count)) = range.split_once(',') {
360        (start.parse().unwrap_or(1), count.parse().unwrap_or(1))
361    } else {
362        (range.parse().unwrap_or(1), 1)
363    }
364}
365
366// ============================================================================
367// State
368// ============================================================================
369
370/// State for the diff viewer widget
371#[derive(Debug, Clone)]
372pub struct DiffViewerState {
373    /// The diff data to display
374    pub diff: DiffData,
375    /// Current view mode
376    pub view_mode: DiffViewMode,
377    /// Vertical scroll position
378    pub scroll_y: usize,
379    /// Horizontal scroll position
380    pub scroll_x: usize,
381    /// Visible viewport height (set during render)
382    pub visible_height: usize,
383    /// Visible viewport width (set during render)
384    pub visible_width: usize,
385    /// Currently selected hunk index (for navigation)
386    pub selected_hunk: Option<usize>,
387    /// Whether to show line numbers
388    pub show_line_numbers: bool,
389    /// Search state
390    pub search: SearchState,
391}
392
393impl DiffViewerState {
394    /// Create a new diff viewer state with diff data
395    pub fn new(diff: DiffData) -> Self {
396        let selected_hunk = if diff.hunks.is_empty() { None } else { Some(0) };
397        Self {
398            diff,
399            view_mode: DiffViewMode::default(),
400            scroll_y: 0,
401            scroll_x: 0,
402            visible_height: 0,
403            visible_width: 0,
404            selected_hunk,
405            show_line_numbers: true,
406            search: SearchState::default(),
407        }
408    }
409
410    /// Create a state from unified diff text
411    pub fn from_unified_diff(text: &str) -> Self {
412        let diff = DiffData::from_unified_diff(text);
413        Self::new(diff)
414    }
415
416    /// Create an empty diff viewer state
417    pub fn empty() -> Self {
418        Self::new(DiffData::empty())
419    }
420
421    /// Set the diff data
422    pub fn set_diff(&mut self, diff: DiffData) {
423        self.diff = diff;
424        self.scroll_y = 0;
425        self.scroll_x = 0;
426        self.selected_hunk = if self.diff.hunks.is_empty() { None } else { Some(0) };
427        self.search.matches.clear();
428    }
429
430    /// Get total line count for scrolling
431    fn total_lines(&self) -> usize {
432        self.diff.hunks.iter().map(|h| h.lines.len() + 1).sum::<usize>() // +1 for hunk header
433    }
434
435    // Navigation methods
436
437    /// Scroll up by one line
438    pub fn scroll_up(&mut self) {
439        self.scroll_y = self.scroll_y.saturating_sub(1);
440    }
441
442    /// Scroll down by one line
443    pub fn scroll_down(&mut self) {
444        let total = self.total_lines();
445        if self.scroll_y + 1 < total {
446            self.scroll_y += 1;
447        }
448    }
449
450    /// Scroll left
451    pub fn scroll_left(&mut self) {
452        self.scroll_x = self.scroll_x.saturating_sub(4);
453    }
454
455    /// Scroll right
456    pub fn scroll_right(&mut self) {
457        self.scroll_x += 4;
458    }
459
460    /// Scroll up by one page
461    pub fn page_up(&mut self) {
462        self.scroll_y = self.scroll_y.saturating_sub(self.visible_height);
463    }
464
465    /// Scroll down by one page
466    pub fn page_down(&mut self) {
467        let total = self.total_lines();
468        let max_scroll = total.saturating_sub(self.visible_height);
469        self.scroll_y = (self.scroll_y + self.visible_height).min(max_scroll);
470    }
471
472    /// Go to top
473    pub fn go_to_top(&mut self) {
474        self.scroll_y = 0;
475        self.selected_hunk = if self.diff.hunks.is_empty() { None } else { Some(0) };
476    }
477
478    /// Go to bottom
479    pub fn go_to_bottom(&mut self) {
480        let total = self.total_lines();
481        self.scroll_y = total.saturating_sub(self.visible_height);
482        self.selected_hunk = if self.diff.hunks.is_empty() { None } else { Some(self.diff.hunks.len() - 1) };
483    }
484
485    /// Go to a specific line (0-indexed)
486    pub fn go_to_line(&mut self, line: usize) {
487        let total = self.total_lines();
488        self.scroll_y = line.min(total.saturating_sub(1));
489    }
490
491    // Hunk navigation
492
493    /// Get the line index where a hunk starts
494    fn hunk_start_line(&self, hunk_index: usize) -> usize {
495        let mut line = 0;
496        for (i, hunk) in self.diff.hunks.iter().enumerate() {
497            if i == hunk_index {
498                return line;
499            }
500            line += hunk.lines.len() + 1; // +1 for hunk header
501        }
502        line
503    }
504
505    /// Jump to the next hunk
506    pub fn next_hunk(&mut self) {
507        if self.diff.hunks.is_empty() {
508            return;
509        }
510        let current = self.selected_hunk.unwrap_or(0);
511        let next = (current + 1).min(self.diff.hunks.len() - 1);
512        self.selected_hunk = Some(next);
513        self.scroll_y = self.hunk_start_line(next);
514    }
515
516    /// Jump to the previous hunk
517    pub fn prev_hunk(&mut self) {
518        if self.diff.hunks.is_empty() {
519            return;
520        }
521        let current = self.selected_hunk.unwrap_or(0);
522        let prev = current.saturating_sub(1);
523        self.selected_hunk = Some(prev);
524        self.scroll_y = self.hunk_start_line(prev);
525    }
526
527    /// Jump to a specific hunk by index
528    pub fn jump_to_hunk(&mut self, index: usize) {
529        if index < self.diff.hunks.len() {
530            self.selected_hunk = Some(index);
531            self.scroll_y = self.hunk_start_line(index);
532        }
533    }
534
535    /// Navigate to next change (addition or deletion)
536    pub fn next_change(&mut self) {
537        let total = self.total_lines();
538        let line_idx = self.scroll_y + 1;
539        let mut running_line = 0;
540
541        for hunk in &self.diff.hunks {
542            // Skip hunk header
543            running_line += 1;
544            if running_line > line_idx {
545                // Check if this is a change line
546                if hunk.lines.first().map(|l| l.line_type != DiffLineType::Context).unwrap_or(false) {
547                    self.scroll_y = running_line - 1;
548                    return;
549                }
550            }
551
552            for line in &hunk.lines {
553                if running_line > line_idx
554                    && (line.line_type == DiffLineType::Addition
555                        || line.line_type == DiffLineType::Deletion)
556                {
557                    self.scroll_y = running_line - 1;
558                    return;
559                }
560                running_line += 1;
561            }
562        }
563
564        // Wrap around to beginning
565        self.scroll_y = 0;
566        if total > 0 {
567            // Find first change
568            running_line = 0;
569            for hunk in &self.diff.hunks {
570                running_line += 1; // hunk header
571                for line in &hunk.lines {
572                    if line.line_type == DiffLineType::Addition || line.line_type == DiffLineType::Deletion {
573                        self.scroll_y = running_line - 1;
574                        return;
575                    }
576                    running_line += 1;
577                }
578            }
579        }
580    }
581
582    /// Navigate to previous change (addition or deletion)
583    pub fn prev_change(&mut self) {
584        if self.scroll_y == 0 {
585            // Start from end
586            self.go_to_bottom();
587        }
588
589        let line_idx = self.scroll_y.saturating_sub(1);
590        let mut changes: Vec<usize> = Vec::new();
591        let mut running_line = 0;
592
593        // Collect all change line positions
594        for hunk in &self.diff.hunks {
595            running_line += 1; // hunk header
596            for line in &hunk.lines {
597                if line.line_type == DiffLineType::Addition || line.line_type == DiffLineType::Deletion {
598                    changes.push(running_line - 1);
599                }
600                running_line += 1;
601            }
602        }
603
604        // Find the closest change before current position
605        for &change_line in changes.iter().rev() {
606            if change_line <= line_idx {
607                self.scroll_y = change_line;
608                return;
609            }
610        }
611
612        // Wrap to last change
613        if let Some(&last) = changes.last() {
614            self.scroll_y = last;
615        }
616    }
617
618    // View mode
619
620    /// Toggle between side-by-side and unified view modes
621    pub fn toggle_view_mode(&mut self) {
622        self.view_mode = match self.view_mode {
623            DiffViewMode::SideBySide => DiffViewMode::Unified,
624            DiffViewMode::Unified => DiffViewMode::SideBySide,
625        };
626    }
627
628    /// Set the view mode
629    pub fn set_view_mode(&mut self, mode: DiffViewMode) {
630        self.view_mode = mode;
631    }
632
633    // Search methods
634
635    /// Start search mode
636    pub fn start_search(&mut self) {
637        self.search.active = true;
638        self.search.query.clear();
639        self.search.matches.clear();
640        self.search.current_match = 0;
641    }
642
643    /// Cancel search mode
644    pub fn cancel_search(&mut self) {
645        self.search.active = false;
646    }
647
648    /// Update search with current query
649    pub fn update_search(&mut self) {
650        self.search.matches.clear();
651        self.search.current_match = 0;
652
653        if self.search.query.is_empty() {
654            return;
655        }
656
657        let query = self.search.query.to_lowercase();
658        let mut line_idx = 0;
659
660        for hunk in &self.diff.hunks {
661            // Check hunk header
662            if hunk.header.to_lowercase().contains(&query) {
663                self.search.matches.push(line_idx);
664            }
665            line_idx += 1;
666
667            // Check lines
668            for line in &hunk.lines {
669                if line.content.to_lowercase().contains(&query) {
670                    self.search.matches.push(line_idx);
671                }
672                line_idx += 1;
673            }
674        }
675
676        // Jump to first match if any
677        if !self.search.matches.is_empty() {
678            self.scroll_y = self.search.matches[0];
679        }
680    }
681
682    /// Go to next search match
683    pub fn next_match(&mut self) {
684        if self.search.matches.is_empty() {
685            return;
686        }
687        self.search.current_match = (self.search.current_match + 1) % self.search.matches.len();
688        self.scroll_y = self.search.matches[self.search.current_match];
689    }
690
691    /// Go to previous search match
692    pub fn prev_match(&mut self) {
693        if self.search.matches.is_empty() {
694            return;
695        }
696        if self.search.current_match == 0 {
697            self.search.current_match = self.search.matches.len() - 1;
698        } else {
699            self.search.current_match -= 1;
700        }
701        self.scroll_y = self.search.matches[self.search.current_match];
702    }
703}
704
705// ============================================================================
706// Style
707// ============================================================================
708
709/// Style configuration for diff viewer
710#[derive(Debug, Clone)]
711pub struct DiffViewerStyle {
712    /// Border style
713    pub border_style: Style,
714    /// Line number style
715    pub line_number_style: Style,
716    /// Context line style (unchanged lines)
717    pub context_style: Style,
718    /// Addition text style
719    pub addition_style: Style,
720    /// Addition background color
721    pub addition_bg: Color,
722    /// Deletion text style
723    pub deletion_style: Style,
724    /// Deletion background color
725    pub deletion_bg: Color,
726    /// Inline addition highlight style (for character-level diffs)
727    pub inline_addition_style: Style,
728    /// Inline deletion highlight style (for character-level diffs)
729    pub inline_deletion_style: Style,
730    /// Hunk header style
731    pub hunk_header_style: Style,
732    /// Search match highlight style
733    pub match_style: Style,
734    /// Current search match highlight style
735    pub current_match_style: Style,
736    /// Gutter separator character
737    pub gutter_separator: &'static str,
738    /// Side-by-side mode separator character
739    pub side_separator: &'static str,
740}
741
742impl Default for DiffViewerStyle {
743    fn default() -> Self {
744        Self {
745            border_style: Style::default().fg(Color::Cyan),
746            line_number_style: Style::default().fg(Color::DarkGray),
747            context_style: Style::default().fg(Color::White),
748            addition_style: Style::default().fg(Color::Green),
749            addition_bg: Color::Rgb(0, 40, 0),
750            deletion_style: Style::default().fg(Color::Red),
751            deletion_bg: Color::Rgb(40, 0, 0),
752            inline_addition_style: Style::default()
753                .fg(Color::Black)
754                .bg(Color::Green)
755                .add_modifier(Modifier::BOLD),
756            inline_deletion_style: Style::default()
757                .fg(Color::Black)
758                .bg(Color::Red)
759                .add_modifier(Modifier::BOLD),
760            hunk_header_style: Style::default()
761                .fg(Color::Cyan)
762                .add_modifier(Modifier::BOLD),
763            match_style: Style::default()
764                .bg(Color::Rgb(60, 60, 30))
765                .fg(Color::Yellow),
766            current_match_style: Style::default()
767                .bg(Color::Yellow)
768                .fg(Color::Black),
769            gutter_separator: "│",
770            side_separator: "│",
771        }
772    }
773}
774
775impl DiffViewerStyle {
776    /// Create a style with high contrast colors
777    pub fn high_contrast() -> Self {
778        Self {
779            addition_style: Style::default().fg(Color::LightGreen),
780            addition_bg: Color::Rgb(0, 60, 0),
781            deletion_style: Style::default().fg(Color::LightRed),
782            deletion_bg: Color::Rgb(60, 0, 0),
783            ..Default::default()
784        }
785    }
786
787    /// Create a monochrome style
788    pub fn monochrome() -> Self {
789        Self {
790            addition_style: Style::default().add_modifier(Modifier::BOLD),
791            addition_bg: Color::Reset,
792            deletion_style: Style::default().add_modifier(Modifier::DIM),
793            deletion_bg: Color::Reset,
794            inline_addition_style: Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
795            inline_deletion_style: Style::default().add_modifier(Modifier::DIM | Modifier::CROSSED_OUT),
796            ..Default::default()
797        }
798    }
799}
800
801// ============================================================================
802// Widget
803// ============================================================================
804
805/// Diff viewer widget
806pub struct DiffViewer<'a> {
807    state: &'a DiffViewerState,
808    style: DiffViewerStyle,
809    title: Option<&'a str>,
810    show_stats: bool,
811}
812
813impl<'a> DiffViewer<'a> {
814    /// Create a new diff viewer
815    pub fn new(state: &'a DiffViewerState) -> Self {
816        Self {
817            state,
818            style: DiffViewerStyle::default(),
819            title: None,
820            show_stats: true,
821        }
822    }
823
824    /// Set the title
825    pub fn title(mut self, title: &'a str) -> Self {
826        self.title = Some(title);
827        self
828    }
829
830    /// Set the style
831    pub fn style(mut self, style: DiffViewerStyle) -> Self {
832        self.style = style;
833        self
834    }
835
836    /// Enable or disable line numbers
837    pub fn show_line_numbers(self, _show: bool) -> Self {
838        // Line numbers are controlled by state, not the widget
839        // This is a no-op provided for API consistency
840        self
841    }
842
843    /// Enable or disable stats display
844    pub fn show_stats(mut self, show: bool) -> Self {
845        self.show_stats = show;
846        self
847    }
848
849    /// Calculate line number width
850    fn line_number_width(&self) -> usize {
851        if !self.state.show_line_numbers {
852            return 0;
853        }
854        // Calculate max line number across all hunks
855        let max_line = self.state.diff.hunks.iter()
856            .map(|h| h.old_start + h.old_count.max(h.new_count))
857            .max()
858            .unwrap_or(1);
859        max_line.to_string().len().max(3)
860    }
861
862    /// Build lines for unified view
863    fn build_unified_lines(&self, inner: Rect) -> Vec<Line<'static>> {
864        let visible_height = inner.height as usize;
865        let line_num_width = self.line_number_width();
866        let visible_width = if self.state.show_line_numbers {
867            inner.width.saturating_sub((line_num_width * 2 + 4) as u16) as usize
868        } else {
869            inner.width.saturating_sub(2) as usize // Just prefix space
870        };
871
872        let mut lines = Vec::new();
873        let mut current_line = 0;
874        let start_line = self.state.scroll_y;
875        let end_line = start_line + visible_height;
876
877        for hunk in &self.state.diff.hunks {
878            // Hunk header
879            if current_line >= start_line && current_line < end_line {
880                let is_match = self.state.search.matches.contains(&current_line);
881                let is_current_match = self.state.search.matches.get(self.state.search.current_match) == Some(&current_line);
882
883                let header_style = if is_current_match {
884                    self.style.current_match_style
885                } else if is_match {
886                    self.style.match_style
887                } else {
888                    self.style.hunk_header_style
889                };
890
891                let header_content: String = hunk.header.chars()
892                    .skip(self.state.scroll_x)
893                    .take(inner.width as usize)
894                    .collect();
895                lines.push(Line::from(Span::styled(header_content, header_style)));
896            }
897            current_line += 1;
898
899            // Hunk lines
900            for line in &hunk.lines {
901                if current_line >= start_line && current_line < end_line {
902                    let is_match = self.state.search.matches.contains(&current_line);
903                    let is_current_match = self.state.search.matches.get(self.state.search.current_match) == Some(&current_line);
904
905                    lines.push(self.build_unified_line(line, line_num_width, visible_width, is_match, is_current_match));
906                }
907                current_line += 1;
908
909                if current_line >= end_line {
910                    break;
911                }
912            }
913
914            if current_line >= end_line {
915                break;
916            }
917        }
918
919        lines
920    }
921
922    /// Build a single unified diff line
923    fn build_unified_line(&self, line: &DiffLine, line_num_width: usize, visible_width: usize, is_match: bool, is_current_match: bool) -> Line<'static> {
924        let mut spans = Vec::new();
925
926        // Line numbers
927        if self.state.show_line_numbers {
928            let old_num = line.old_line_num
929                .map(|n| format!("{:>width$}", n, width = line_num_width))
930                .unwrap_or_else(|| " ".repeat(line_num_width));
931            let new_num = line.new_line_num
932                .map(|n| format!("{:>width$}", n, width = line_num_width))
933                .unwrap_or_else(|| " ".repeat(line_num_width));
934
935            spans.push(Span::styled(old_num, self.style.line_number_style));
936            spans.push(Span::styled(" ", self.style.line_number_style));
937            spans.push(Span::styled(new_num, self.style.line_number_style));
938            spans.push(Span::styled(format!(" {} ", self.style.gutter_separator), self.style.line_number_style));
939        }
940
941        // Prefix and content
942        let (prefix, content_style, bg_style) = match line.line_type {
943            DiffLineType::Context => (" ", self.style.context_style, Style::default()),
944            DiffLineType::Addition => ("+", self.style.addition_style, Style::default().bg(self.style.addition_bg)),
945            DiffLineType::Deletion => ("-", self.style.deletion_style, Style::default().bg(self.style.deletion_bg)),
946            DiffLineType::HunkHeader => ("@", self.style.hunk_header_style, Style::default()),
947        };
948
949        // Apply search highlighting
950        let final_style = if is_current_match {
951            self.style.current_match_style
952        } else if is_match {
953            self.style.match_style
954        } else {
955            content_style.patch(bg_style)
956        };
957
958        spans.push(Span::styled(prefix.to_string(), final_style));
959
960        // Content with horizontal scroll
961        let content: String = line.content.chars()
962            .skip(self.state.scroll_x)
963            .take(visible_width)
964            .collect();
965
966        spans.push(Span::styled(content, final_style));
967
968        Line::from(spans)
969    }
970
971    /// Build lines for side-by-side view
972    fn build_side_by_side_lines(&self, inner: Rect) -> Vec<Line<'static>> {
973        let visible_height = inner.height as usize;
974        let half_width = (inner.width.saturating_sub(1) / 2) as usize; // -1 for separator
975        let line_num_width = self.line_number_width();
976        let content_width = if self.state.show_line_numbers {
977            half_width.saturating_sub(line_num_width + 3) // line num + prefix + separator
978        } else {
979            half_width.saturating_sub(2) // Just prefix
980        };
981
982        let mut lines = Vec::new();
983        let mut current_line = 0;
984        let start_line = self.state.scroll_y;
985        let end_line = start_line + visible_height;
986
987        for hunk in &self.state.diff.hunks {
988            // Hunk header (spans both sides)
989            if current_line >= start_line && current_line < end_line {
990                let header_style = self.style.hunk_header_style;
991                let header_content: String = hunk.header.chars()
992                    .skip(self.state.scroll_x)
993                    .take(inner.width as usize)
994                    .collect();
995                lines.push(Line::from(Span::styled(header_content, header_style)));
996            }
997            current_line += 1;
998
999            // Process lines in pairs for side-by-side
1000            let paired_lines = self.pair_lines_for_side_by_side(&hunk.lines);
1001
1002            for (old_line, new_line) in paired_lines {
1003                if current_line >= start_line && current_line < end_line {
1004                    lines.push(self.build_side_by_side_line(
1005                        old_line,
1006                        new_line,
1007                        line_num_width,
1008                        content_width,
1009                        half_width,
1010                    ));
1011                }
1012                current_line += 1;
1013
1014                if current_line >= end_line {
1015                    break;
1016                }
1017            }
1018
1019            if current_line >= end_line {
1020                break;
1021            }
1022        }
1023
1024        lines
1025    }
1026
1027    /// Pair deletion/addition lines for side-by-side display
1028    fn pair_lines_for_side_by_side<'b>(&self, lines: &'b [DiffLine]) -> Vec<(Option<&'b DiffLine>, Option<&'b DiffLine>)> {
1029        let mut pairs = Vec::new();
1030        let mut deletions: Vec<&DiffLine> = Vec::new();
1031        let mut additions: Vec<&DiffLine> = Vec::new();
1032
1033        for line in lines {
1034            match line.line_type {
1035                DiffLineType::Context => {
1036                    // Flush any pending deletions/additions
1037                    Self::flush_changes(&mut pairs, &mut deletions, &mut additions);
1038                    pairs.push((Some(line), Some(line)));
1039                }
1040                DiffLineType::Deletion => {
1041                    deletions.push(line);
1042                }
1043                DiffLineType::Addition => {
1044                    additions.push(line);
1045                }
1046                DiffLineType::HunkHeader => {
1047                    // Shouldn't happen here
1048                }
1049            }
1050        }
1051
1052        // Flush remaining
1053        Self::flush_changes(&mut pairs, &mut deletions, &mut additions);
1054
1055        pairs
1056    }
1057
1058    /// Flush accumulated deletions and additions into pairs
1059    fn flush_changes<'b>(
1060        pairs: &mut Vec<(Option<&'b DiffLine>, Option<&'b DiffLine>)>,
1061        deletions: &mut Vec<&'b DiffLine>,
1062        additions: &mut Vec<&'b DiffLine>,
1063    ) {
1064        let max_len = deletions.len().max(additions.len());
1065        for i in 0..max_len {
1066            let del = deletions.get(i).copied();
1067            let add = additions.get(i).copied();
1068            pairs.push((del, add));
1069        }
1070        deletions.clear();
1071        additions.clear();
1072    }
1073
1074    /// Build a side-by-side line
1075    fn build_side_by_side_line(
1076        &self,
1077        old_line: Option<&DiffLine>,
1078        new_line: Option<&DiffLine>,
1079        line_num_width: usize,
1080        content_width: usize,
1081        half_width: usize,
1082    ) -> Line<'static> {
1083        let mut spans = Vec::new();
1084
1085        // Left side (old)
1086        spans.extend(self.build_half_line(old_line, line_num_width, content_width, true));
1087
1088        // Pad to half width
1089        let left_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
1090        if left_len < half_width {
1091            spans.push(Span::raw(" ".repeat(half_width - left_len)));
1092        }
1093
1094        // Separator
1095        spans.push(Span::styled(self.style.side_separator, Style::default().fg(Color::DarkGray)));
1096
1097        // Right side (new)
1098        spans.extend(self.build_half_line(new_line, line_num_width, content_width, false));
1099
1100        Line::from(spans)
1101    }
1102
1103    /// Build one half of a side-by-side line
1104    fn build_half_line(&self, line: Option<&DiffLine>, line_num_width: usize, content_width: usize, is_old: bool) -> Vec<Span<'static>> {
1105        let mut spans = Vec::new();
1106
1107        match line {
1108            Some(l) => {
1109                // Line number
1110                if self.state.show_line_numbers {
1111                    let num = if is_old { l.old_line_num } else { l.new_line_num };
1112                    let num_str = num
1113                        .map(|n| format!("{:>width$}", n, width = line_num_width))
1114                        .unwrap_or_else(|| " ".repeat(line_num_width));
1115                    spans.push(Span::styled(num_str, self.style.line_number_style));
1116                    spans.push(Span::raw(" "));
1117                }
1118
1119                // Determine style based on line type and side
1120                let (prefix, style, bg) = match l.line_type {
1121                    DiffLineType::Context => (" ", self.style.context_style, Style::default()),
1122                    DiffLineType::Addition => ("+", self.style.addition_style, Style::default().bg(self.style.addition_bg)),
1123                    DiffLineType::Deletion => ("-", self.style.deletion_style, Style::default().bg(self.style.deletion_bg)),
1124                    DiffLineType::HunkHeader => ("@", self.style.hunk_header_style, Style::default()),
1125                };
1126
1127                let final_style = style.patch(bg);
1128
1129                spans.push(Span::styled(prefix.to_string(), final_style));
1130
1131                // Content with scroll
1132                let content: String = l.content.chars()
1133                    .skip(self.state.scroll_x)
1134                    .take(content_width)
1135                    .collect();
1136                spans.push(Span::styled(content, final_style));
1137            }
1138            None => {
1139                // Empty half
1140                if self.state.show_line_numbers {
1141                    spans.push(Span::raw(" ".repeat(line_num_width + 1)));
1142                }
1143                spans.push(Span::raw(" ".repeat(content_width + 1)));
1144            }
1145        }
1146
1147        spans
1148    }
1149}
1150
1151impl Widget for DiffViewer<'_> {
1152    fn render(self, area: Rect, buf: &mut Buffer) {
1153        // Layout: content + status bar + optional search bar
1154        let constraints = if self.state.search.active {
1155            vec![
1156                Constraint::Min(1),
1157                Constraint::Length(1),
1158                Constraint::Length(1),
1159            ]
1160        } else {
1161            vec![Constraint::Min(1), Constraint::Length(1)]
1162        };
1163
1164        let chunks = Layout::default()
1165            .direction(Direction::Vertical)
1166            .constraints(constraints)
1167            .split(area);
1168
1169        // Build title with stats
1170        let title_text = if let Some(t) = self.title {
1171            if self.show_stats {
1172                let additions = self.state.diff.total_additions();
1173                let deletions = self.state.diff.total_deletions();
1174                format!(" {} (+{} -{}) ", t, additions, deletions)
1175            } else {
1176                format!(" {} ", t)
1177            }
1178        } else if self.show_stats {
1179            let additions = self.state.diff.total_additions();
1180            let deletions = self.state.diff.total_deletions();
1181            format!(" +{} -{} ", additions, deletions)
1182        } else {
1183            String::new()
1184        };
1185
1186        let block = Block::default()
1187            .title(title_text)
1188            .borders(Borders::ALL)
1189            .border_style(self.style.border_style);
1190
1191        let inner = block.inner(chunks[0]);
1192        block.render(chunks[0], buf);
1193
1194        // Content
1195        let lines = match self.state.view_mode {
1196            DiffViewMode::Unified => self.build_unified_lines(inner),
1197            DiffViewMode::SideBySide => self.build_side_by_side_lines(inner),
1198        };
1199
1200        let para = Paragraph::new(lines);
1201        para.render(inner, buf);
1202
1203        // Scrollbar
1204        let total_lines = self.state.total_lines();
1205        if total_lines > inner.height as usize {
1206            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
1207            let mut scrollbar_state = ScrollbarState::new(total_lines).position(self.state.scroll_y);
1208            scrollbar.render(inner, buf, &mut scrollbar_state);
1209        }
1210
1211        // Status bar
1212        render_diff_status_bar(self.state, &self.style, chunks[1], buf);
1213
1214        // Search bar
1215        if self.state.search.active && chunks.len() > 2 {
1216            render_diff_search_bar(self.state, chunks[2], buf);
1217        }
1218    }
1219}
1220
1221/// Render the status bar
1222fn render_diff_status_bar(state: &DiffViewerState, _style: &DiffViewerStyle, area: Rect, buf: &mut Buffer) {
1223    let total_lines = state.total_lines();
1224    let current_line = state.scroll_y + 1;
1225    let percent = if total_lines > 0 {
1226        (current_line as f64 / total_lines as f64 * 100.0) as u16
1227    } else {
1228        0
1229    };
1230
1231    let mode_str = match state.view_mode {
1232        DiffViewMode::Unified => "Unified",
1233        DiffViewMode::SideBySide => "Side-by-Side",
1234    };
1235
1236    let hunk_info = if let Some(hunk_idx) = state.selected_hunk {
1237        format!(" | Hunk {}/{}", hunk_idx + 1, state.diff.hunks.len())
1238    } else {
1239        String::new()
1240    };
1241
1242    let h_scroll_info = if state.scroll_x > 0 {
1243        format!(" | Col: {}", state.scroll_x + 1)
1244    } else {
1245        String::new()
1246    };
1247
1248    let search_info = if !state.search.matches.is_empty() {
1249        format!(
1250            " | Match {}/{}",
1251            state.search.current_match + 1,
1252            state.search.matches.len()
1253        )
1254    } else if !state.search.query.is_empty() && state.search.matches.is_empty() {
1255        " | No matches".to_string()
1256    } else {
1257        String::new()
1258    };
1259
1260    let status = Line::from(vec![
1261        Span::styled(" j/k", Style::default().fg(Color::Yellow)),
1262        Span::raw(": scroll "),
1263        Span::styled("]/[", Style::default().fg(Color::Yellow)),
1264        Span::raw(": hunk "),
1265        Span::styled("n/N", Style::default().fg(Color::Yellow)),
1266        Span::raw(": change "),
1267        Span::styled("v", Style::default().fg(Color::Yellow)),
1268        Span::raw(": mode "),
1269        Span::styled("/", Style::default().fg(Color::Yellow)),
1270        Span::raw(": search | "),
1271        Span::raw(format!(
1272            "{} | Line {}/{} ({}%){}{}{}",
1273            mode_str, current_line, total_lines, percent, hunk_info, h_scroll_info, search_info
1274        )),
1275    ]);
1276
1277    let para = Paragraph::new(status).style(Style::default().bg(Color::DarkGray));
1278    para.render(area, buf);
1279}
1280
1281/// Render the search bar
1282fn render_diff_search_bar(state: &DiffViewerState, area: Rect, buf: &mut Buffer) {
1283    let search_line = Line::from(vec![
1284        Span::styled(" Search: ", Style::default().fg(Color::Yellow)),
1285        Span::raw(state.search.query.clone()),
1286        Span::styled("▌", Style::default().fg(Color::White)),
1287    ]);
1288
1289    let para = Paragraph::new(search_line).style(Style::default().bg(Color::Rgb(40, 40, 60)));
1290    para.render(area, buf);
1291}
1292
1293// ============================================================================
1294// Event Handlers
1295// ============================================================================
1296
1297/// Handle keyboard input for diff viewer
1298///
1299/// Returns true if the key was handled
1300pub fn handle_diff_viewer_key(state: &mut DiffViewerState, key: &KeyEvent) -> bool {
1301    // Search mode handling
1302    if state.search.active {
1303        match key.code {
1304            KeyCode::Esc => {
1305                state.cancel_search();
1306                return true;
1307            }
1308            KeyCode::Enter => {
1309                state.search.active = false;
1310                return true;
1311            }
1312            KeyCode::Backspace => {
1313                state.search.query.pop();
1314                state.update_search();
1315                return true;
1316            }
1317            KeyCode::Char(c) => {
1318                state.search.query.push(c);
1319                state.update_search();
1320                return true;
1321            }
1322            _ => return false,
1323        }
1324    }
1325
1326    match key.code {
1327        // Vertical scroll
1328        KeyCode::Char('j') | KeyCode::Down => {
1329            state.scroll_down();
1330            true
1331        }
1332        KeyCode::Char('k') | KeyCode::Up => {
1333            state.scroll_up();
1334            true
1335        }
1336
1337        // Horizontal scroll
1338        KeyCode::Char('h') | KeyCode::Left => {
1339            state.scroll_left();
1340            true
1341        }
1342        KeyCode::Char('l') | KeyCode::Right => {
1343            state.scroll_right();
1344            true
1345        }
1346
1347        // Page navigation
1348        KeyCode::PageDown => {
1349            state.page_down();
1350            true
1351        }
1352        KeyCode::PageUp => {
1353            state.page_up();
1354            true
1355        }
1356        KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1357            state.page_down();
1358            true
1359        }
1360        KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1361            state.page_up();
1362            true
1363        }
1364
1365        // Top/Bottom
1366        KeyCode::Char('g') => {
1367            state.go_to_top();
1368            true
1369        }
1370        KeyCode::Char('G') => {
1371            state.go_to_bottom();
1372            true
1373        }
1374        KeyCode::Home => {
1375            state.go_to_top();
1376            true
1377        }
1378        KeyCode::End => {
1379            state.go_to_bottom();
1380            true
1381        }
1382
1383        // Hunk navigation
1384        KeyCode::Char(']') => {
1385            state.next_hunk();
1386            true
1387        }
1388        KeyCode::Char('[') => {
1389            state.prev_hunk();
1390            true
1391        }
1392
1393        // Change navigation
1394        KeyCode::Char('n') => {
1395            if state.search.matches.is_empty() {
1396                state.next_change();
1397            } else {
1398                state.next_match();
1399            }
1400            true
1401        }
1402        KeyCode::Char('N') => {
1403            if state.search.matches.is_empty() {
1404                state.prev_change();
1405            } else {
1406                state.prev_match();
1407            }
1408            true
1409        }
1410
1411        // View mode toggle
1412        KeyCode::Char('v') | KeyCode::Char('m') => {
1413            state.toggle_view_mode();
1414            true
1415        }
1416
1417        // Search
1418        KeyCode::Char('/') => {
1419            state.start_search();
1420            true
1421        }
1422
1423        _ => false,
1424    }
1425}
1426
1427/// Handle mouse input for diff viewer
1428///
1429/// Returns an action if one was triggered
1430pub fn handle_diff_viewer_mouse(state: &mut DiffViewerState, mouse: &MouseEvent) -> Option<DiffViewerAction> {
1431    match mouse.kind {
1432        MouseEventKind::ScrollDown => {
1433            state.scroll_down();
1434            state.scroll_down();
1435            state.scroll_down();
1436            None
1437        }
1438        MouseEventKind::ScrollUp => {
1439            state.scroll_up();
1440            state.scroll_up();
1441            state.scroll_up();
1442            None
1443        }
1444        _ => None,
1445    }
1446}
1447
1448// ============================================================================
1449// Tests
1450// ============================================================================
1451
1452#[cfg(test)]
1453mod tests {
1454    use super::*;
1455
1456    const SAMPLE_DIFF: &str = r#"--- a/file.txt
1457+++ b/file.txt
1458@@ -1,5 +1,6 @@
1459 context line 1
1460-removed line
1461+added line
1462+another added line
1463 context line 2
1464 context line 3
1465"#;
1466
1467    #[test]
1468    fn test_parse_unified_diff_basic() {
1469        let diff = DiffData::from_unified_diff(SAMPLE_DIFF);
1470
1471        assert_eq!(diff.old_path, Some("file.txt".to_string()));
1472        assert_eq!(diff.new_path, Some("file.txt".to_string()));
1473        assert_eq!(diff.hunks.len(), 1);
1474
1475        let hunk = &diff.hunks[0];
1476        assert_eq!(hunk.old_start, 1);
1477        assert_eq!(hunk.old_count, 5);
1478        assert_eq!(hunk.new_start, 1);
1479        assert_eq!(hunk.new_count, 6);
1480    }
1481
1482    #[test]
1483    fn test_parse_unified_diff_lines() {
1484        let diff = DiffData::from_unified_diff(SAMPLE_DIFF);
1485        let hunk = &diff.hunks[0];
1486
1487        // 6 lines: context, deletion, addition, addition, context, context
1488        assert_eq!(hunk.lines.len(), 6);
1489        assert_eq!(hunk.lines[0].line_type, DiffLineType::Context);
1490        assert_eq!(hunk.lines[1].line_type, DiffLineType::Deletion);
1491        assert_eq!(hunk.lines[2].line_type, DiffLineType::Addition);
1492        assert_eq!(hunk.lines[3].line_type, DiffLineType::Addition);
1493        assert_eq!(hunk.lines[4].line_type, DiffLineType::Context);
1494        assert_eq!(hunk.lines[5].line_type, DiffLineType::Context);
1495    }
1496
1497    #[test]
1498    fn test_parse_unified_diff_line_numbers() {
1499        let diff = DiffData::from_unified_diff(SAMPLE_DIFF);
1500        let hunk = &diff.hunks[0];
1501
1502        // Context line 1
1503        assert_eq!(hunk.lines[0].old_line_num, Some(1));
1504        assert_eq!(hunk.lines[0].new_line_num, Some(1));
1505
1506        // Deletion (removed line)
1507        assert_eq!(hunk.lines[1].old_line_num, Some(2));
1508        assert_eq!(hunk.lines[1].new_line_num, None);
1509
1510        // Addition (added line)
1511        assert_eq!(hunk.lines[2].old_line_num, None);
1512        assert_eq!(hunk.lines[2].new_line_num, Some(2));
1513    }
1514
1515    #[test]
1516    fn test_diff_statistics() {
1517        let diff = DiffData::from_unified_diff(SAMPLE_DIFF);
1518
1519        assert_eq!(diff.total_additions(), 2);
1520        assert_eq!(diff.total_deletions(), 1);
1521    }
1522
1523    #[test]
1524    fn test_state_new() {
1525        let diff = DiffData::from_unified_diff(SAMPLE_DIFF);
1526        let state = DiffViewerState::new(diff);
1527
1528        assert_eq!(state.scroll_y, 0);
1529        assert_eq!(state.scroll_x, 0);
1530        assert_eq!(state.view_mode, DiffViewMode::Unified);
1531        assert_eq!(state.selected_hunk, Some(0));
1532    }
1533
1534    #[test]
1535    fn test_state_from_unified_diff() {
1536        let state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1537
1538        assert!(!state.diff.hunks.is_empty());
1539        assert_eq!(state.diff.total_additions(), 2);
1540    }
1541
1542    #[test]
1543    fn test_state_scroll() {
1544        let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1545
1546        assert_eq!(state.scroll_y, 0);
1547        state.scroll_down();
1548        assert_eq!(state.scroll_y, 1);
1549        state.scroll_up();
1550        assert_eq!(state.scroll_y, 0);
1551        state.scroll_up(); // Should not go negative
1552        assert_eq!(state.scroll_y, 0);
1553    }
1554
1555    #[test]
1556    fn test_horizontal_scroll() {
1557        let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1558
1559        state.scroll_right();
1560        assert_eq!(state.scroll_x, 4);
1561        state.scroll_right();
1562        assert_eq!(state.scroll_x, 8);
1563        state.scroll_left();
1564        assert_eq!(state.scroll_x, 4);
1565        state.scroll_left();
1566        assert_eq!(state.scroll_x, 0);
1567        state.scroll_left(); // Should not go negative
1568        assert_eq!(state.scroll_x, 0);
1569    }
1570
1571    #[test]
1572    fn test_page_navigation() {
1573        let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1574        state.visible_height = 2;
1575
1576        state.page_down();
1577        assert_eq!(state.scroll_y, 2);
1578        state.page_up();
1579        assert_eq!(state.scroll_y, 0);
1580    }
1581
1582    #[test]
1583    fn test_go_to_top_bottom() {
1584        let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1585        state.visible_height = 2;
1586
1587        state.go_to_bottom();
1588        assert!(state.scroll_y > 0);
1589
1590        state.go_to_top();
1591        assert_eq!(state.scroll_y, 0);
1592    }
1593
1594    #[test]
1595    fn test_view_mode_toggle() {
1596        let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1597
1598        assert_eq!(state.view_mode, DiffViewMode::Unified);
1599        state.toggle_view_mode();
1600        assert_eq!(state.view_mode, DiffViewMode::SideBySide);
1601        state.toggle_view_mode();
1602        assert_eq!(state.view_mode, DiffViewMode::Unified);
1603    }
1604
1605    #[test]
1606    fn test_hunk_navigation() {
1607        let multi_hunk_diff = r#"--- a/file.txt
1608+++ b/file.txt
1609@@ -1,3 +1,3 @@
1610 line 1
1611-old line 2
1612+new line 2
1613 line 3
1614@@ -10,3 +10,3 @@
1615 line 10
1616-old line 11
1617+new line 11
1618 line 12
1619"#;
1620        let mut state = DiffViewerState::from_unified_diff(multi_hunk_diff);
1621
1622        assert_eq!(state.selected_hunk, Some(0));
1623        state.next_hunk();
1624        assert_eq!(state.selected_hunk, Some(1));
1625        state.next_hunk(); // Should stay at last
1626        assert_eq!(state.selected_hunk, Some(1));
1627        state.prev_hunk();
1628        assert_eq!(state.selected_hunk, Some(0));
1629        state.prev_hunk(); // Should stay at first
1630        assert_eq!(state.selected_hunk, Some(0));
1631    }
1632
1633    #[test]
1634    fn test_search() {
1635        let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1636
1637        state.start_search();
1638        state.search.query = "added".to_string();
1639        state.update_search();
1640
1641        assert!(!state.search.matches.is_empty());
1642    }
1643
1644    #[test]
1645    fn test_search_next_prev() {
1646        let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1647
1648        state.search.query = "line".to_string();
1649        state.update_search();
1650
1651        let initial_match = state.search.current_match;
1652        state.next_match();
1653        assert_ne!(state.search.current_match, initial_match);
1654        state.prev_match();
1655        assert_eq!(state.search.current_match, initial_match);
1656    }
1657
1658    #[test]
1659    fn test_empty_state() {
1660        let state = DiffViewerState::empty();
1661        assert!(state.diff.hunks.is_empty());
1662        assert_eq!(state.selected_hunk, None);
1663    }
1664
1665    #[test]
1666    fn test_parse_hunk_header() {
1667        let result = parse_hunk_header("@@ -1,3 +1,4 @@");
1668        assert_eq!(result, Some((1, 3, 1, 4)));
1669
1670        let result = parse_hunk_header("@@ -10 +20,5 @@");
1671        assert_eq!(result, Some((10, 1, 20, 5)));
1672
1673        let result = parse_hunk_header("@@ -1,2 +3,4 @@ function name");
1674        assert_eq!(result, Some((1, 2, 3, 4)));
1675    }
1676
1677    #[test]
1678    fn test_diff_line_constructors() {
1679        let context = DiffLine::context("test".to_string(), 1, 2);
1680        assert_eq!(context.line_type, DiffLineType::Context);
1681        assert_eq!(context.old_line_num, Some(1));
1682        assert_eq!(context.new_line_num, Some(2));
1683
1684        let addition = DiffLine::addition("new".to_string(), 5);
1685        assert_eq!(addition.line_type, DiffLineType::Addition);
1686        assert_eq!(addition.new_line_num, Some(5));
1687        assert_eq!(addition.old_line_num, None);
1688
1689        let deletion = DiffLine::deletion("old".to_string(), 3);
1690        assert_eq!(deletion.line_type, DiffLineType::Deletion);
1691        assert_eq!(deletion.old_line_num, Some(3));
1692        assert_eq!(deletion.new_line_num, None);
1693    }
1694
1695    #[test]
1696    fn test_diff_hunk_counts() {
1697        let mut hunk = DiffHunk::new("@@ -1,3 +1,4 @@".to_string(), 1, 3, 1, 4);
1698        hunk.add_line(DiffLine::context("ctx".to_string(), 1, 1));
1699        hunk.add_line(DiffLine::deletion("del".to_string(), 2));
1700        hunk.add_line(DiffLine::addition("add1".to_string(), 2));
1701        hunk.add_line(DiffLine::addition("add2".to_string(), 3));
1702
1703        assert_eq!(hunk.addition_count(), 2);
1704        assert_eq!(hunk.deletion_count(), 1);
1705    }
1706
1707    #[test]
1708    fn test_style_default() {
1709        let style = DiffViewerStyle::default();
1710        assert_eq!(style.gutter_separator, "│");
1711        assert_eq!(style.side_separator, "│");
1712    }
1713
1714    #[test]
1715    fn test_key_handler_scroll() {
1716        let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1717
1718        let key_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
1719        assert!(handle_diff_viewer_key(&mut state, &key_j));
1720        assert_eq!(state.scroll_y, 1);
1721
1722        let key_k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
1723        assert!(handle_diff_viewer_key(&mut state, &key_k));
1724        assert_eq!(state.scroll_y, 0);
1725    }
1726
1727    #[test]
1728    fn test_key_handler_view_mode() {
1729        let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1730
1731        let key_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
1732        assert!(handle_diff_viewer_key(&mut state, &key_v));
1733        assert_eq!(state.view_mode, DiffViewMode::SideBySide);
1734    }
1735
1736    #[test]
1737    fn test_key_handler_search() {
1738        let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1739
1740        let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
1741        assert!(handle_diff_viewer_key(&mut state, &key_slash));
1742        assert!(state.search.active);
1743
1744        let key_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
1745        assert!(handle_diff_viewer_key(&mut state, &key_a));
1746        assert_eq!(state.search.query, "a");
1747
1748        let key_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1749        assert!(handle_diff_viewer_key(&mut state, &key_esc));
1750        assert!(!state.search.active);
1751    }
1752
1753    #[test]
1754    fn test_render_does_not_panic() {
1755        let state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1756        let viewer = DiffViewer::new(&state).title("Test Diff");
1757
1758        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 20));
1759        viewer.render(Rect::new(0, 0, 80, 20), &mut buf);
1760    }
1761
1762    #[test]
1763    fn test_render_side_by_side_does_not_panic() {
1764        let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1765        state.view_mode = DiffViewMode::SideBySide;
1766        let viewer = DiffViewer::new(&state);
1767
1768        let mut buf = Buffer::empty(Rect::new(0, 0, 120, 20));
1769        viewer.render(Rect::new(0, 0, 120, 20), &mut buf);
1770    }
1771}