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