1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum DiffViewMode {
54 SideBySide,
56 #[default]
58 Unified,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum DiffLineType {
64 Context,
66 Addition,
68 Deletion,
70 HunkHeader,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum DiffViewerAction {
77 ScrollToLine(usize),
79 JumpToHunk(usize),
81 ToggleViewMode,
83}
84
85#[derive(Debug, Clone)]
91pub struct DiffLine {
92 pub line_type: DiffLineType,
94 pub content: String,
96 pub old_line_num: Option<usize>,
98 pub new_line_num: Option<usize>,
100 pub inline_changes: Vec<(usize, usize)>,
102}
103
104impl DiffLine {
105 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 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 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 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 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 pub fn with_inline_changes(mut self, changes: Vec<(usize, usize)>) -> Self {
162 self.inline_changes = changes;
163 self
164 }
165}
166
167#[derive(Debug, Clone)]
169pub struct DiffHunk {
170 pub header: String,
172 pub old_start: usize,
174 pub old_count: usize,
176 pub new_start: usize,
178 pub new_count: usize,
180 pub lines: Vec<DiffLine>,
182}
183
184impl DiffHunk {
185 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 pub fn add_line(&mut self, line: DiffLine) {
205 self.lines.push(line);
206 }
207
208 pub fn addition_count(&self) -> usize {
210 self.lines
211 .iter()
212 .filter(|l| l.line_type == DiffLineType::Addition)
213 .count()
214 }
215
216 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#[derive(Debug, Clone, Default)]
227pub struct DiffData {
228 pub old_path: Option<String>,
230 pub new_path: Option<String>,
232 pub hunks: Vec<DiffHunk>,
234}
235
236impl DiffData {
237 pub fn empty() -> Self {
239 Self::default()
240 }
241
242 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 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 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 if line.starts_with("@@") {
271 if let Some(hunk) = current_hunk.take() {
273 diff.hunks.push(hunk);
274 }
275
276 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 if let Some(hunk) = current_hunk.as_mut() {
294 if let Some(content) = line.strip_prefix('+') {
295 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 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 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 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 if let Some(hunk) = current_hunk {
324 diff.hunks.push(hunk);
325 }
326
327 diff
328 }
329
330 pub fn total_additions(&self) -> usize {
332 self.hunks.iter().map(|h| h.addition_count()).sum()
333 }
334
335 pub fn total_deletions(&self) -> usize {
337 self.hunks.iter().map(|h| h.deletion_count()).sum()
338 }
339
340 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 pub fn is_empty(&self) -> bool {
353 self.hunks.is_empty()
354 }
355}
356
357fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> {
359 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
374fn 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#[derive(Debug, Clone)]
389pub struct DiffViewerState {
390 pub diff: DiffData,
392 pub view_mode: DiffViewMode,
394 pub scroll_y: usize,
396 pub scroll_x: usize,
398 pub visible_height: usize,
400 pub visible_width: usize,
402 pub selected_hunk: Option<usize>,
404 pub show_line_numbers: bool,
406 pub search: SearchState,
408}
409
410impl DiffViewerState {
411 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 pub fn from_unified_diff(text: &str) -> Self {
429 let diff = DiffData::from_unified_diff(text);
430 Self::new(diff)
431 }
432
433 pub fn empty() -> Self {
435 Self::new(DiffData::empty())
436 }
437
438 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 fn total_lines(&self) -> usize {
453 self.diff
454 .hunks
455 .iter()
456 .map(|h| h.lines.len() + 1)
457 .sum::<usize>() }
459
460 pub fn scroll_up(&mut self) {
464 self.scroll_y = self.scroll_y.saturating_sub(1);
465 }
466
467 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 pub fn scroll_left(&mut self) {
477 self.scroll_x = self.scroll_x.saturating_sub(4);
478 }
479
480 pub fn scroll_right(&mut self) {
482 self.scroll_x += 4;
483 }
484
485 pub fn page_up(&mut self) {
487 self.scroll_y = self.scroll_y.saturating_sub(self.visible_height);
488 }
489
490 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 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 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 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 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; }
535 line
536 }
537
538 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 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 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 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 running_line += 1;
577 if running_line > line_idx {
578 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 self.scroll_y = 0;
604 if total > 0 {
605 running_line = 0;
607 for hunk in &self.diff.hunks {
608 running_line += 1; 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 pub fn prev_change(&mut self) {
624 if self.scroll_y == 0 {
625 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 for hunk in &self.diff.hunks {
635 running_line += 1; 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 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 if let Some(&last) = changes.last() {
656 self.scroll_y = last;
657 }
658 }
659
660 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 pub fn set_view_mode(&mut self, mode: DiffViewMode) {
672 self.view_mode = mode;
673 }
674
675 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 pub fn cancel_search(&mut self) {
687 self.search.active = false;
688 }
689
690 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 if hunk.header.to_lowercase().contains(&query) {
705 self.search.matches.push(line_idx);
706 }
707 line_idx += 1;
708
709 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 if !self.search.matches.is_empty() {
720 self.scroll_y = self.search.matches[0];
721 }
722 }
723
724 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 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#[derive(Debug, Clone)]
753pub struct DiffViewerStyle {
754 pub border_style: Style,
756 pub line_number_style: Style,
758 pub context_style: Style,
760 pub addition_style: Style,
762 pub addition_bg: Color,
764 pub deletion_style: Style,
766 pub deletion_bg: Color,
768 pub inline_addition_style: Style,
770 pub inline_deletion_style: Style,
772 pub hunk_header_style: Style,
774 pub match_style: Style,
776 pub current_match_style: Style,
778 pub gutter_separator: &'static str,
780 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 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 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
873pub 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 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 pub fn title(mut self, title: &'a str) -> Self {
898 self.title = Some(title);
899 self
900 }
901
902 pub fn style(mut self, style: DiffViewerStyle) -> Self {
904 self.style = style;
905 self
906 }
907
908 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
910 self.style(DiffViewerStyle::from(theme))
911 }
912
913 pub fn show_line_numbers(self, _show: bool) -> Self {
915 self
918 }
919
920 pub fn show_stats(mut self, show: bool) -> Self {
922 self.show_stats = show;
923 self
924 }
925
926 fn line_number_width(&self) -> usize {
928 if !self.state.show_line_numbers {
929 return 0;
930 }
931 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 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 };
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 if current_line >= start_line && current_line < end_line {
961 let is_match = self.state.search.matches.contains(¤t_line);
962 let is_current_match = self
963 .state
964 .search
965 .matches
966 .get(self.state.search.current_match)
967 == Some(¤t_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 for line in &hunk.lines {
989 if current_line >= start_line && current_line < end_line {
990 let is_match = self.state.search.matches.contains(¤t_line);
991 let is_current_match = self
992 .state
993 .search
994 .matches
995 .get(self.state.search.current_match)
996 == Some(¤t_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 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 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 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 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 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 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; 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) } else {
1100 half_width.saturating_sub(2) };
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 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 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 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 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 }
1175 }
1176 }
1177
1178 Self::flush_changes(&mut pairs, &mut deletions, &mut additions);
1180
1181 pairs
1182 }
1183
1184 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 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 spans.extend(self.build_half_line(old_line, line_num_width, content_width, true));
1213
1214 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 spans.push(Span::styled(
1222 self.style.side_separator,
1223 Style::default().fg(Color::DarkGray),
1224 ));
1225
1226 spans.extend(self.build_half_line(new_line, line_num_width, content_width, false));
1228
1229 Line::from(spans)
1230 }
1231
1232 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 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 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 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 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 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 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 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 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 render_diff_status_bar(self.state, &self.style, chunks[1], buf);
1365
1366 if self.state.search.active && chunks.len() > 2 {
1368 render_diff_search_bar(self.state, chunks[2], buf);
1369 }
1370 }
1371}
1372
1373fn 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
1438fn 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
1450pub fn handle_diff_viewer_key(state: &mut DiffViewerState, key: &KeyEvent) -> bool {
1458 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 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 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 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 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 KeyCode::Char(']') => {
1542 state.next_hunk();
1543 true
1544 }
1545 KeyCode::Char('[') => {
1546 state.prev_hunk();
1547 true
1548 }
1549
1550 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 KeyCode::Char('v') | KeyCode::Char('m') => {
1570 state.toggle_view_mode();
1571 true
1572 }
1573
1574 KeyCode::Char('/') => {
1576 state.start_search();
1577 true
1578 }
1579
1580 _ => false,
1581 }
1582}
1583
1584pub 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#[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 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 assert_eq!(hunk.lines[0].old_line_num, Some(1));
1664 assert_eq!(hunk.lines[0].new_line_num, Some(1));
1665
1666 assert_eq!(hunk.lines[1].old_line_num, Some(2));
1668 assert_eq!(hunk.lines[1].new_line_num, None);
1669
1670 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(); 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(); 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(); assert_eq!(state.selected_hunk, Some(1));
1787 state.prev_hunk();
1788 assert_eq!(state.selected_hunk, Some(0));
1789 state.prev_hunk(); 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}