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(header: String, old_start: usize, old_count: usize, new_start: usize, new_count: usize) -> Self {
187 Self {
188 header,
189 old_start,
190 old_count,
191 new_start,
192 new_count,
193 lines: Vec::new(),
194 }
195 }
196
197 pub fn add_line(&mut self, line: DiffLine) {
199 self.lines.push(line);
200 }
201
202 pub fn addition_count(&self) -> usize {
204 self.lines.iter().filter(|l| l.line_type == DiffLineType::Addition).count()
205 }
206
207 pub fn deletion_count(&self) -> usize {
209 self.lines.iter().filter(|l| l.line_type == DiffLineType::Deletion).count()
210 }
211}
212
213#[derive(Debug, Clone, Default)]
215pub struct DiffData {
216 pub old_path: Option<String>,
218 pub new_path: Option<String>,
220 pub hunks: Vec<DiffHunk>,
222}
223
224impl DiffData {
225 pub fn empty() -> Self {
227 Self::default()
228 }
229
230 pub fn new(old_path: Option<String>, new_path: Option<String>) -> Self {
232 Self {
233 old_path,
234 new_path,
235 hunks: Vec::new(),
236 }
237 }
238
239 pub fn from_unified_diff(text: &str) -> Self {
241 let mut diff = DiffData::empty();
242 let mut current_hunk: Option<DiffHunk> = None;
243 let mut old_line_num: usize = 0;
244 let mut new_line_num: usize = 0;
245
246 for line in text.lines() {
247 if let Some(path) = line.strip_prefix("--- ") {
249 diff.old_path = Some(path.trim_start_matches("a/").to_string());
250 continue;
251 }
252 if let Some(path) = line.strip_prefix("+++ ") {
253 diff.new_path = Some(path.trim_start_matches("b/").to_string());
254 continue;
255 }
256
257 if line.starts_with("@@") {
259 if let Some(hunk) = current_hunk.take() {
261 diff.hunks.push(hunk);
262 }
263
264 if let Some((old_start, old_count, new_start, new_count)) = parse_hunk_header(line) {
266 current_hunk = Some(DiffHunk::new(
267 line.to_string(),
268 old_start,
269 old_count,
270 new_start,
271 new_count,
272 ));
273 old_line_num = old_start;
274 new_line_num = new_start;
275 }
276 continue;
277 }
278
279 if let Some(hunk) = current_hunk.as_mut() {
281 if let Some(content) = line.strip_prefix('+') {
282 hunk.add_line(DiffLine::addition(content.to_string(), new_line_num));
284 new_line_num += 1;
285 } else if let Some(content) = line.strip_prefix('-') {
286 hunk.add_line(DiffLine::deletion(content.to_string(), old_line_num));
288 old_line_num += 1;
289 } else if let Some(content) = line.strip_prefix(' ') {
290 hunk.add_line(DiffLine::context(content.to_string(), old_line_num, new_line_num));
292 old_line_num += 1;
293 new_line_num += 1;
294 } else if line.is_empty() || line == "\\ No newline at end of file" {
295 if line.is_empty() {
297 hunk.add_line(DiffLine::context(String::new(), old_line_num, new_line_num));
298 old_line_num += 1;
299 new_line_num += 1;
300 }
301 }
302 }
303 }
304
305 if let Some(hunk) = current_hunk {
307 diff.hunks.push(hunk);
308 }
309
310 diff
311 }
312
313 pub fn total_additions(&self) -> usize {
315 self.hunks.iter().map(|h| h.addition_count()).sum()
316 }
317
318 pub fn total_deletions(&self) -> usize {
320 self.hunks.iter().map(|h| h.deletion_count()).sum()
321 }
322
323 pub fn all_lines(&self) -> Vec<&DiffLine> {
325 let mut lines = Vec::new();
326 for hunk in &self.hunks {
327 for line in &hunk.lines {
328 lines.push(line);
329 }
330 }
331 lines
332 }
333
334 pub fn is_empty(&self) -> bool {
336 self.hunks.is_empty()
337 }
338}
339
340fn parse_hunk_header(line: &str) -> Option<(usize, usize, usize, usize)> {
342 let content = line.trim_start_matches("@@ ").trim_end();
344 let end_marker_pos = content.find(" @@")?;
345 let ranges = &content[..end_marker_pos];
346
347 let mut parts = ranges.split_whitespace();
348 let old_range = parts.next()?.strip_prefix('-')?;
349 let new_range = parts.next()?.strip_prefix('+')?;
350
351 let (old_start, old_count) = parse_range(old_range);
352 let (new_start, new_count) = parse_range(new_range);
353
354 Some((old_start, old_count, new_start, new_count))
355}
356
357fn parse_range(range: &str) -> (usize, usize) {
359 if let Some((start, count)) = range.split_once(',') {
360 (start.parse().unwrap_or(1), count.parse().unwrap_or(1))
361 } else {
362 (range.parse().unwrap_or(1), 1)
363 }
364}
365
366#[derive(Debug, Clone)]
372pub struct DiffViewerState {
373 pub diff: DiffData,
375 pub view_mode: DiffViewMode,
377 pub scroll_y: usize,
379 pub scroll_x: usize,
381 pub visible_height: usize,
383 pub visible_width: usize,
385 pub selected_hunk: Option<usize>,
387 pub show_line_numbers: bool,
389 pub search: SearchState,
391}
392
393impl DiffViewerState {
394 pub fn new(diff: DiffData) -> Self {
396 let selected_hunk = if diff.hunks.is_empty() { None } else { Some(0) };
397 Self {
398 diff,
399 view_mode: DiffViewMode::default(),
400 scroll_y: 0,
401 scroll_x: 0,
402 visible_height: 0,
403 visible_width: 0,
404 selected_hunk,
405 show_line_numbers: true,
406 search: SearchState::default(),
407 }
408 }
409
410 pub fn from_unified_diff(text: &str) -> Self {
412 let diff = DiffData::from_unified_diff(text);
413 Self::new(diff)
414 }
415
416 pub fn empty() -> Self {
418 Self::new(DiffData::empty())
419 }
420
421 pub fn set_diff(&mut self, diff: DiffData) {
423 self.diff = diff;
424 self.scroll_y = 0;
425 self.scroll_x = 0;
426 self.selected_hunk = if self.diff.hunks.is_empty() { None } else { Some(0) };
427 self.search.matches.clear();
428 }
429
430 fn total_lines(&self) -> usize {
432 self.diff.hunks.iter().map(|h| h.lines.len() + 1).sum::<usize>() }
434
435 pub fn scroll_up(&mut self) {
439 self.scroll_y = self.scroll_y.saturating_sub(1);
440 }
441
442 pub fn scroll_down(&mut self) {
444 let total = self.total_lines();
445 if self.scroll_y + 1 < total {
446 self.scroll_y += 1;
447 }
448 }
449
450 pub fn scroll_left(&mut self) {
452 self.scroll_x = self.scroll_x.saturating_sub(4);
453 }
454
455 pub fn scroll_right(&mut self) {
457 self.scroll_x += 4;
458 }
459
460 pub fn page_up(&mut self) {
462 self.scroll_y = self.scroll_y.saturating_sub(self.visible_height);
463 }
464
465 pub fn page_down(&mut self) {
467 let total = self.total_lines();
468 let max_scroll = total.saturating_sub(self.visible_height);
469 self.scroll_y = (self.scroll_y + self.visible_height).min(max_scroll);
470 }
471
472 pub fn go_to_top(&mut self) {
474 self.scroll_y = 0;
475 self.selected_hunk = if self.diff.hunks.is_empty() { None } else { Some(0) };
476 }
477
478 pub fn go_to_bottom(&mut self) {
480 let total = self.total_lines();
481 self.scroll_y = total.saturating_sub(self.visible_height);
482 self.selected_hunk = if self.diff.hunks.is_empty() { None } else { Some(self.diff.hunks.len() - 1) };
483 }
484
485 pub fn go_to_line(&mut self, line: usize) {
487 let total = self.total_lines();
488 self.scroll_y = line.min(total.saturating_sub(1));
489 }
490
491 fn hunk_start_line(&self, hunk_index: usize) -> usize {
495 let mut line = 0;
496 for (i, hunk) in self.diff.hunks.iter().enumerate() {
497 if i == hunk_index {
498 return line;
499 }
500 line += hunk.lines.len() + 1; }
502 line
503 }
504
505 pub fn next_hunk(&mut self) {
507 if self.diff.hunks.is_empty() {
508 return;
509 }
510 let current = self.selected_hunk.unwrap_or(0);
511 let next = (current + 1).min(self.diff.hunks.len() - 1);
512 self.selected_hunk = Some(next);
513 self.scroll_y = self.hunk_start_line(next);
514 }
515
516 pub fn prev_hunk(&mut self) {
518 if self.diff.hunks.is_empty() {
519 return;
520 }
521 let current = self.selected_hunk.unwrap_or(0);
522 let prev = current.saturating_sub(1);
523 self.selected_hunk = Some(prev);
524 self.scroll_y = self.hunk_start_line(prev);
525 }
526
527 pub fn jump_to_hunk(&mut self, index: usize) {
529 if index < self.diff.hunks.len() {
530 self.selected_hunk = Some(index);
531 self.scroll_y = self.hunk_start_line(index);
532 }
533 }
534
535 pub fn next_change(&mut self) {
537 let total = self.total_lines();
538 let line_idx = self.scroll_y + 1;
539 let mut running_line = 0;
540
541 for hunk in &self.diff.hunks {
542 running_line += 1;
544 if running_line > line_idx {
545 if hunk.lines.first().map(|l| l.line_type != DiffLineType::Context).unwrap_or(false) {
547 self.scroll_y = running_line - 1;
548 return;
549 }
550 }
551
552 for line in &hunk.lines {
553 if running_line > line_idx
554 && (line.line_type == DiffLineType::Addition
555 || line.line_type == DiffLineType::Deletion)
556 {
557 self.scroll_y = running_line - 1;
558 return;
559 }
560 running_line += 1;
561 }
562 }
563
564 self.scroll_y = 0;
566 if total > 0 {
567 running_line = 0;
569 for hunk in &self.diff.hunks {
570 running_line += 1; for line in &hunk.lines {
572 if line.line_type == DiffLineType::Addition || line.line_type == DiffLineType::Deletion {
573 self.scroll_y = running_line - 1;
574 return;
575 }
576 running_line += 1;
577 }
578 }
579 }
580 }
581
582 pub fn prev_change(&mut self) {
584 if self.scroll_y == 0 {
585 self.go_to_bottom();
587 }
588
589 let line_idx = self.scroll_y.saturating_sub(1);
590 let mut changes: Vec<usize> = Vec::new();
591 let mut running_line = 0;
592
593 for hunk in &self.diff.hunks {
595 running_line += 1; for line in &hunk.lines {
597 if line.line_type == DiffLineType::Addition || line.line_type == DiffLineType::Deletion {
598 changes.push(running_line - 1);
599 }
600 running_line += 1;
601 }
602 }
603
604 for &change_line in changes.iter().rev() {
606 if change_line <= line_idx {
607 self.scroll_y = change_line;
608 return;
609 }
610 }
611
612 if let Some(&last) = changes.last() {
614 self.scroll_y = last;
615 }
616 }
617
618 pub fn toggle_view_mode(&mut self) {
622 self.view_mode = match self.view_mode {
623 DiffViewMode::SideBySide => DiffViewMode::Unified,
624 DiffViewMode::Unified => DiffViewMode::SideBySide,
625 };
626 }
627
628 pub fn set_view_mode(&mut self, mode: DiffViewMode) {
630 self.view_mode = mode;
631 }
632
633 pub fn start_search(&mut self) {
637 self.search.active = true;
638 self.search.query.clear();
639 self.search.matches.clear();
640 self.search.current_match = 0;
641 }
642
643 pub fn cancel_search(&mut self) {
645 self.search.active = false;
646 }
647
648 pub fn update_search(&mut self) {
650 self.search.matches.clear();
651 self.search.current_match = 0;
652
653 if self.search.query.is_empty() {
654 return;
655 }
656
657 let query = self.search.query.to_lowercase();
658 let mut line_idx = 0;
659
660 for hunk in &self.diff.hunks {
661 if hunk.header.to_lowercase().contains(&query) {
663 self.search.matches.push(line_idx);
664 }
665 line_idx += 1;
666
667 for line in &hunk.lines {
669 if line.content.to_lowercase().contains(&query) {
670 self.search.matches.push(line_idx);
671 }
672 line_idx += 1;
673 }
674 }
675
676 if !self.search.matches.is_empty() {
678 self.scroll_y = self.search.matches[0];
679 }
680 }
681
682 pub fn next_match(&mut self) {
684 if self.search.matches.is_empty() {
685 return;
686 }
687 self.search.current_match = (self.search.current_match + 1) % self.search.matches.len();
688 self.scroll_y = self.search.matches[self.search.current_match];
689 }
690
691 pub fn prev_match(&mut self) {
693 if self.search.matches.is_empty() {
694 return;
695 }
696 if self.search.current_match == 0 {
697 self.search.current_match = self.search.matches.len() - 1;
698 } else {
699 self.search.current_match -= 1;
700 }
701 self.scroll_y = self.search.matches[self.search.current_match];
702 }
703}
704
705#[derive(Debug, Clone)]
711pub struct DiffViewerStyle {
712 pub border_style: Style,
714 pub line_number_style: Style,
716 pub context_style: Style,
718 pub addition_style: Style,
720 pub addition_bg: Color,
722 pub deletion_style: Style,
724 pub deletion_bg: Color,
726 pub inline_addition_style: Style,
728 pub inline_deletion_style: Style,
730 pub hunk_header_style: Style,
732 pub match_style: Style,
734 pub current_match_style: Style,
736 pub gutter_separator: &'static str,
738 pub side_separator: &'static str,
740}
741
742impl Default for DiffViewerStyle {
743 fn default() -> Self {
744 Self {
745 border_style: Style::default().fg(Color::Cyan),
746 line_number_style: Style::default().fg(Color::DarkGray),
747 context_style: Style::default().fg(Color::White),
748 addition_style: Style::default().fg(Color::Green),
749 addition_bg: Color::Rgb(0, 40, 0),
750 deletion_style: Style::default().fg(Color::Red),
751 deletion_bg: Color::Rgb(40, 0, 0),
752 inline_addition_style: Style::default()
753 .fg(Color::Black)
754 .bg(Color::Green)
755 .add_modifier(Modifier::BOLD),
756 inline_deletion_style: Style::default()
757 .fg(Color::Black)
758 .bg(Color::Red)
759 .add_modifier(Modifier::BOLD),
760 hunk_header_style: Style::default()
761 .fg(Color::Cyan)
762 .add_modifier(Modifier::BOLD),
763 match_style: Style::default()
764 .bg(Color::Rgb(60, 60, 30))
765 .fg(Color::Yellow),
766 current_match_style: Style::default()
767 .bg(Color::Yellow)
768 .fg(Color::Black),
769 gutter_separator: "│",
770 side_separator: "│",
771 }
772 }
773}
774
775impl DiffViewerStyle {
776 pub fn high_contrast() -> Self {
778 Self {
779 addition_style: Style::default().fg(Color::LightGreen),
780 addition_bg: Color::Rgb(0, 60, 0),
781 deletion_style: Style::default().fg(Color::LightRed),
782 deletion_bg: Color::Rgb(60, 0, 0),
783 ..Default::default()
784 }
785 }
786
787 pub fn monochrome() -> Self {
789 Self {
790 addition_style: Style::default().add_modifier(Modifier::BOLD),
791 addition_bg: Color::Reset,
792 deletion_style: Style::default().add_modifier(Modifier::DIM),
793 deletion_bg: Color::Reset,
794 inline_addition_style: Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
795 inline_deletion_style: Style::default().add_modifier(Modifier::DIM | Modifier::CROSSED_OUT),
796 ..Default::default()
797 }
798 }
799}
800
801pub struct DiffViewer<'a> {
807 state: &'a DiffViewerState,
808 style: DiffViewerStyle,
809 title: Option<&'a str>,
810 show_stats: bool,
811}
812
813impl<'a> DiffViewer<'a> {
814 pub fn new(state: &'a DiffViewerState) -> Self {
816 Self {
817 state,
818 style: DiffViewerStyle::default(),
819 title: None,
820 show_stats: true,
821 }
822 }
823
824 pub fn title(mut self, title: &'a str) -> Self {
826 self.title = Some(title);
827 self
828 }
829
830 pub fn style(mut self, style: DiffViewerStyle) -> Self {
832 self.style = style;
833 self
834 }
835
836 pub fn show_line_numbers(self, _show: bool) -> Self {
838 self
841 }
842
843 pub fn show_stats(mut self, show: bool) -> Self {
845 self.show_stats = show;
846 self
847 }
848
849 fn line_number_width(&self) -> usize {
851 if !self.state.show_line_numbers {
852 return 0;
853 }
854 let max_line = self.state.diff.hunks.iter()
856 .map(|h| h.old_start + h.old_count.max(h.new_count))
857 .max()
858 .unwrap_or(1);
859 max_line.to_string().len().max(3)
860 }
861
862 fn build_unified_lines(&self, inner: Rect) -> Vec<Line<'static>> {
864 let visible_height = inner.height as usize;
865 let line_num_width = self.line_number_width();
866 let visible_width = if self.state.show_line_numbers {
867 inner.width.saturating_sub((line_num_width * 2 + 4) as u16) as usize
868 } else {
869 inner.width.saturating_sub(2) as usize };
871
872 let mut lines = Vec::new();
873 let mut current_line = 0;
874 let start_line = self.state.scroll_y;
875 let end_line = start_line + visible_height;
876
877 for hunk in &self.state.diff.hunks {
878 if current_line >= start_line && current_line < end_line {
880 let is_match = self.state.search.matches.contains(¤t_line);
881 let is_current_match = self.state.search.matches.get(self.state.search.current_match) == Some(¤t_line);
882
883 let header_style = if is_current_match {
884 self.style.current_match_style
885 } else if is_match {
886 self.style.match_style
887 } else {
888 self.style.hunk_header_style
889 };
890
891 let header_content: String = hunk.header.chars()
892 .skip(self.state.scroll_x)
893 .take(inner.width as usize)
894 .collect();
895 lines.push(Line::from(Span::styled(header_content, header_style)));
896 }
897 current_line += 1;
898
899 for line in &hunk.lines {
901 if current_line >= start_line && current_line < end_line {
902 let is_match = self.state.search.matches.contains(¤t_line);
903 let is_current_match = self.state.search.matches.get(self.state.search.current_match) == Some(¤t_line);
904
905 lines.push(self.build_unified_line(line, line_num_width, visible_width, is_match, is_current_match));
906 }
907 current_line += 1;
908
909 if current_line >= end_line {
910 break;
911 }
912 }
913
914 if current_line >= end_line {
915 break;
916 }
917 }
918
919 lines
920 }
921
922 fn build_unified_line(&self, line: &DiffLine, line_num_width: usize, visible_width: usize, is_match: bool, is_current_match: bool) -> Line<'static> {
924 let mut spans = Vec::new();
925
926 if self.state.show_line_numbers {
928 let old_num = line.old_line_num
929 .map(|n| format!("{:>width$}", n, width = line_num_width))
930 .unwrap_or_else(|| " ".repeat(line_num_width));
931 let new_num = line.new_line_num
932 .map(|n| format!("{:>width$}", n, width = line_num_width))
933 .unwrap_or_else(|| " ".repeat(line_num_width));
934
935 spans.push(Span::styled(old_num, self.style.line_number_style));
936 spans.push(Span::styled(" ", self.style.line_number_style));
937 spans.push(Span::styled(new_num, self.style.line_number_style));
938 spans.push(Span::styled(format!(" {} ", self.style.gutter_separator), self.style.line_number_style));
939 }
940
941 let (prefix, content_style, bg_style) = match line.line_type {
943 DiffLineType::Context => (" ", self.style.context_style, Style::default()),
944 DiffLineType::Addition => ("+", self.style.addition_style, Style::default().bg(self.style.addition_bg)),
945 DiffLineType::Deletion => ("-", self.style.deletion_style, Style::default().bg(self.style.deletion_bg)),
946 DiffLineType::HunkHeader => ("@", self.style.hunk_header_style, Style::default()),
947 };
948
949 let final_style = if is_current_match {
951 self.style.current_match_style
952 } else if is_match {
953 self.style.match_style
954 } else {
955 content_style.patch(bg_style)
956 };
957
958 spans.push(Span::styled(prefix.to_string(), final_style));
959
960 let content: String = line.content.chars()
962 .skip(self.state.scroll_x)
963 .take(visible_width)
964 .collect();
965
966 spans.push(Span::styled(content, final_style));
967
968 Line::from(spans)
969 }
970
971 fn build_side_by_side_lines(&self, inner: Rect) -> Vec<Line<'static>> {
973 let visible_height = inner.height as usize;
974 let half_width = (inner.width.saturating_sub(1) / 2) as usize; let line_num_width = self.line_number_width();
976 let content_width = if self.state.show_line_numbers {
977 half_width.saturating_sub(line_num_width + 3) } else {
979 half_width.saturating_sub(2) };
981
982 let mut lines = Vec::new();
983 let mut current_line = 0;
984 let start_line = self.state.scroll_y;
985 let end_line = start_line + visible_height;
986
987 for hunk in &self.state.diff.hunks {
988 if current_line >= start_line && current_line < end_line {
990 let header_style = self.style.hunk_header_style;
991 let header_content: String = hunk.header.chars()
992 .skip(self.state.scroll_x)
993 .take(inner.width as usize)
994 .collect();
995 lines.push(Line::from(Span::styled(header_content, header_style)));
996 }
997 current_line += 1;
998
999 let paired_lines = self.pair_lines_for_side_by_side(&hunk.lines);
1001
1002 for (old_line, new_line) in paired_lines {
1003 if current_line >= start_line && current_line < end_line {
1004 lines.push(self.build_side_by_side_line(
1005 old_line,
1006 new_line,
1007 line_num_width,
1008 content_width,
1009 half_width,
1010 ));
1011 }
1012 current_line += 1;
1013
1014 if current_line >= end_line {
1015 break;
1016 }
1017 }
1018
1019 if current_line >= end_line {
1020 break;
1021 }
1022 }
1023
1024 lines
1025 }
1026
1027 fn pair_lines_for_side_by_side<'b>(&self, lines: &'b [DiffLine]) -> Vec<(Option<&'b DiffLine>, Option<&'b DiffLine>)> {
1029 let mut pairs = Vec::new();
1030 let mut deletions: Vec<&DiffLine> = Vec::new();
1031 let mut additions: Vec<&DiffLine> = Vec::new();
1032
1033 for line in lines {
1034 match line.line_type {
1035 DiffLineType::Context => {
1036 Self::flush_changes(&mut pairs, &mut deletions, &mut additions);
1038 pairs.push((Some(line), Some(line)));
1039 }
1040 DiffLineType::Deletion => {
1041 deletions.push(line);
1042 }
1043 DiffLineType::Addition => {
1044 additions.push(line);
1045 }
1046 DiffLineType::HunkHeader => {
1047 }
1049 }
1050 }
1051
1052 Self::flush_changes(&mut pairs, &mut deletions, &mut additions);
1054
1055 pairs
1056 }
1057
1058 fn flush_changes<'b>(
1060 pairs: &mut Vec<(Option<&'b DiffLine>, Option<&'b DiffLine>)>,
1061 deletions: &mut Vec<&'b DiffLine>,
1062 additions: &mut Vec<&'b DiffLine>,
1063 ) {
1064 let max_len = deletions.len().max(additions.len());
1065 for i in 0..max_len {
1066 let del = deletions.get(i).copied();
1067 let add = additions.get(i).copied();
1068 pairs.push((del, add));
1069 }
1070 deletions.clear();
1071 additions.clear();
1072 }
1073
1074 fn build_side_by_side_line(
1076 &self,
1077 old_line: Option<&DiffLine>,
1078 new_line: Option<&DiffLine>,
1079 line_num_width: usize,
1080 content_width: usize,
1081 half_width: usize,
1082 ) -> Line<'static> {
1083 let mut spans = Vec::new();
1084
1085 spans.extend(self.build_half_line(old_line, line_num_width, content_width, true));
1087
1088 let left_len: usize = spans.iter().map(|s| s.content.chars().count()).sum();
1090 if left_len < half_width {
1091 spans.push(Span::raw(" ".repeat(half_width - left_len)));
1092 }
1093
1094 spans.push(Span::styled(self.style.side_separator, Style::default().fg(Color::DarkGray)));
1096
1097 spans.extend(self.build_half_line(new_line, line_num_width, content_width, false));
1099
1100 Line::from(spans)
1101 }
1102
1103 fn build_half_line(&self, line: Option<&DiffLine>, line_num_width: usize, content_width: usize, is_old: bool) -> Vec<Span<'static>> {
1105 let mut spans = Vec::new();
1106
1107 match line {
1108 Some(l) => {
1109 if self.state.show_line_numbers {
1111 let num = if is_old { l.old_line_num } else { l.new_line_num };
1112 let num_str = num
1113 .map(|n| format!("{:>width$}", n, width = line_num_width))
1114 .unwrap_or_else(|| " ".repeat(line_num_width));
1115 spans.push(Span::styled(num_str, self.style.line_number_style));
1116 spans.push(Span::raw(" "));
1117 }
1118
1119 let (prefix, style, bg) = match l.line_type {
1121 DiffLineType::Context => (" ", self.style.context_style, Style::default()),
1122 DiffLineType::Addition => ("+", self.style.addition_style, Style::default().bg(self.style.addition_bg)),
1123 DiffLineType::Deletion => ("-", self.style.deletion_style, Style::default().bg(self.style.deletion_bg)),
1124 DiffLineType::HunkHeader => ("@", self.style.hunk_header_style, Style::default()),
1125 };
1126
1127 let final_style = style.patch(bg);
1128
1129 spans.push(Span::styled(prefix.to_string(), final_style));
1130
1131 let content: String = l.content.chars()
1133 .skip(self.state.scroll_x)
1134 .take(content_width)
1135 .collect();
1136 spans.push(Span::styled(content, final_style));
1137 }
1138 None => {
1139 if self.state.show_line_numbers {
1141 spans.push(Span::raw(" ".repeat(line_num_width + 1)));
1142 }
1143 spans.push(Span::raw(" ".repeat(content_width + 1)));
1144 }
1145 }
1146
1147 spans
1148 }
1149}
1150
1151impl Widget for DiffViewer<'_> {
1152 fn render(self, area: Rect, buf: &mut Buffer) {
1153 let constraints = if self.state.search.active {
1155 vec![
1156 Constraint::Min(1),
1157 Constraint::Length(1),
1158 Constraint::Length(1),
1159 ]
1160 } else {
1161 vec![Constraint::Min(1), Constraint::Length(1)]
1162 };
1163
1164 let chunks = Layout::default()
1165 .direction(Direction::Vertical)
1166 .constraints(constraints)
1167 .split(area);
1168
1169 let title_text = if let Some(t) = self.title {
1171 if self.show_stats {
1172 let additions = self.state.diff.total_additions();
1173 let deletions = self.state.diff.total_deletions();
1174 format!(" {} (+{} -{}) ", t, additions, deletions)
1175 } else {
1176 format!(" {} ", t)
1177 }
1178 } else if self.show_stats {
1179 let additions = self.state.diff.total_additions();
1180 let deletions = self.state.diff.total_deletions();
1181 format!(" +{} -{} ", additions, deletions)
1182 } else {
1183 String::new()
1184 };
1185
1186 let block = Block::default()
1187 .title(title_text)
1188 .borders(Borders::ALL)
1189 .border_style(self.style.border_style);
1190
1191 let inner = block.inner(chunks[0]);
1192 block.render(chunks[0], buf);
1193
1194 let lines = match self.state.view_mode {
1196 DiffViewMode::Unified => self.build_unified_lines(inner),
1197 DiffViewMode::SideBySide => self.build_side_by_side_lines(inner),
1198 };
1199
1200 let para = Paragraph::new(lines);
1201 para.render(inner, buf);
1202
1203 let total_lines = self.state.total_lines();
1205 if total_lines > inner.height as usize {
1206 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight);
1207 let mut scrollbar_state = ScrollbarState::new(total_lines).position(self.state.scroll_y);
1208 scrollbar.render(inner, buf, &mut scrollbar_state);
1209 }
1210
1211 render_diff_status_bar(self.state, &self.style, chunks[1], buf);
1213
1214 if self.state.search.active && chunks.len() > 2 {
1216 render_diff_search_bar(self.state, chunks[2], buf);
1217 }
1218 }
1219}
1220
1221fn render_diff_status_bar(state: &DiffViewerState, _style: &DiffViewerStyle, area: Rect, buf: &mut Buffer) {
1223 let total_lines = state.total_lines();
1224 let current_line = state.scroll_y + 1;
1225 let percent = if total_lines > 0 {
1226 (current_line as f64 / total_lines as f64 * 100.0) as u16
1227 } else {
1228 0
1229 };
1230
1231 let mode_str = match state.view_mode {
1232 DiffViewMode::Unified => "Unified",
1233 DiffViewMode::SideBySide => "Side-by-Side",
1234 };
1235
1236 let hunk_info = if let Some(hunk_idx) = state.selected_hunk {
1237 format!(" | Hunk {}/{}", hunk_idx + 1, state.diff.hunks.len())
1238 } else {
1239 String::new()
1240 };
1241
1242 let h_scroll_info = if state.scroll_x > 0 {
1243 format!(" | Col: {}", state.scroll_x + 1)
1244 } else {
1245 String::new()
1246 };
1247
1248 let search_info = if !state.search.matches.is_empty() {
1249 format!(
1250 " | Match {}/{}",
1251 state.search.current_match + 1,
1252 state.search.matches.len()
1253 )
1254 } else if !state.search.query.is_empty() && state.search.matches.is_empty() {
1255 " | No matches".to_string()
1256 } else {
1257 String::new()
1258 };
1259
1260 let status = Line::from(vec![
1261 Span::styled(" j/k", Style::default().fg(Color::Yellow)),
1262 Span::raw(": scroll "),
1263 Span::styled("]/[", Style::default().fg(Color::Yellow)),
1264 Span::raw(": hunk "),
1265 Span::styled("n/N", Style::default().fg(Color::Yellow)),
1266 Span::raw(": change "),
1267 Span::styled("v", Style::default().fg(Color::Yellow)),
1268 Span::raw(": mode "),
1269 Span::styled("/", Style::default().fg(Color::Yellow)),
1270 Span::raw(": search | "),
1271 Span::raw(format!(
1272 "{} | Line {}/{} ({}%){}{}{}",
1273 mode_str, current_line, total_lines, percent, hunk_info, h_scroll_info, search_info
1274 )),
1275 ]);
1276
1277 let para = Paragraph::new(status).style(Style::default().bg(Color::DarkGray));
1278 para.render(area, buf);
1279}
1280
1281fn render_diff_search_bar(state: &DiffViewerState, area: Rect, buf: &mut Buffer) {
1283 let search_line = Line::from(vec![
1284 Span::styled(" Search: ", Style::default().fg(Color::Yellow)),
1285 Span::raw(state.search.query.clone()),
1286 Span::styled("▌", Style::default().fg(Color::White)),
1287 ]);
1288
1289 let para = Paragraph::new(search_line).style(Style::default().bg(Color::Rgb(40, 40, 60)));
1290 para.render(area, buf);
1291}
1292
1293pub fn handle_diff_viewer_key(state: &mut DiffViewerState, key: &KeyEvent) -> bool {
1301 if state.search.active {
1303 match key.code {
1304 KeyCode::Esc => {
1305 state.cancel_search();
1306 return true;
1307 }
1308 KeyCode::Enter => {
1309 state.search.active = false;
1310 return true;
1311 }
1312 KeyCode::Backspace => {
1313 state.search.query.pop();
1314 state.update_search();
1315 return true;
1316 }
1317 KeyCode::Char(c) => {
1318 state.search.query.push(c);
1319 state.update_search();
1320 return true;
1321 }
1322 _ => return false,
1323 }
1324 }
1325
1326 match key.code {
1327 KeyCode::Char('j') | KeyCode::Down => {
1329 state.scroll_down();
1330 true
1331 }
1332 KeyCode::Char('k') | KeyCode::Up => {
1333 state.scroll_up();
1334 true
1335 }
1336
1337 KeyCode::Char('h') | KeyCode::Left => {
1339 state.scroll_left();
1340 true
1341 }
1342 KeyCode::Char('l') | KeyCode::Right => {
1343 state.scroll_right();
1344 true
1345 }
1346
1347 KeyCode::PageDown => {
1349 state.page_down();
1350 true
1351 }
1352 KeyCode::PageUp => {
1353 state.page_up();
1354 true
1355 }
1356 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1357 state.page_down();
1358 true
1359 }
1360 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1361 state.page_up();
1362 true
1363 }
1364
1365 KeyCode::Char('g') => {
1367 state.go_to_top();
1368 true
1369 }
1370 KeyCode::Char('G') => {
1371 state.go_to_bottom();
1372 true
1373 }
1374 KeyCode::Home => {
1375 state.go_to_top();
1376 true
1377 }
1378 KeyCode::End => {
1379 state.go_to_bottom();
1380 true
1381 }
1382
1383 KeyCode::Char(']') => {
1385 state.next_hunk();
1386 true
1387 }
1388 KeyCode::Char('[') => {
1389 state.prev_hunk();
1390 true
1391 }
1392
1393 KeyCode::Char('n') => {
1395 if state.search.matches.is_empty() {
1396 state.next_change();
1397 } else {
1398 state.next_match();
1399 }
1400 true
1401 }
1402 KeyCode::Char('N') => {
1403 if state.search.matches.is_empty() {
1404 state.prev_change();
1405 } else {
1406 state.prev_match();
1407 }
1408 true
1409 }
1410
1411 KeyCode::Char('v') | KeyCode::Char('m') => {
1413 state.toggle_view_mode();
1414 true
1415 }
1416
1417 KeyCode::Char('/') => {
1419 state.start_search();
1420 true
1421 }
1422
1423 _ => false,
1424 }
1425}
1426
1427pub fn handle_diff_viewer_mouse(state: &mut DiffViewerState, mouse: &MouseEvent) -> Option<DiffViewerAction> {
1431 match mouse.kind {
1432 MouseEventKind::ScrollDown => {
1433 state.scroll_down();
1434 state.scroll_down();
1435 state.scroll_down();
1436 None
1437 }
1438 MouseEventKind::ScrollUp => {
1439 state.scroll_up();
1440 state.scroll_up();
1441 state.scroll_up();
1442 None
1443 }
1444 _ => None,
1445 }
1446}
1447
1448#[cfg(test)]
1453mod tests {
1454 use super::*;
1455
1456 const SAMPLE_DIFF: &str = r#"--- a/file.txt
1457+++ b/file.txt
1458@@ -1,5 +1,6 @@
1459 context line 1
1460-removed line
1461+added line
1462+another added line
1463 context line 2
1464 context line 3
1465"#;
1466
1467 #[test]
1468 fn test_parse_unified_diff_basic() {
1469 let diff = DiffData::from_unified_diff(SAMPLE_DIFF);
1470
1471 assert_eq!(diff.old_path, Some("file.txt".to_string()));
1472 assert_eq!(diff.new_path, Some("file.txt".to_string()));
1473 assert_eq!(diff.hunks.len(), 1);
1474
1475 let hunk = &diff.hunks[0];
1476 assert_eq!(hunk.old_start, 1);
1477 assert_eq!(hunk.old_count, 5);
1478 assert_eq!(hunk.new_start, 1);
1479 assert_eq!(hunk.new_count, 6);
1480 }
1481
1482 #[test]
1483 fn test_parse_unified_diff_lines() {
1484 let diff = DiffData::from_unified_diff(SAMPLE_DIFF);
1485 let hunk = &diff.hunks[0];
1486
1487 assert_eq!(hunk.lines.len(), 6);
1489 assert_eq!(hunk.lines[0].line_type, DiffLineType::Context);
1490 assert_eq!(hunk.lines[1].line_type, DiffLineType::Deletion);
1491 assert_eq!(hunk.lines[2].line_type, DiffLineType::Addition);
1492 assert_eq!(hunk.lines[3].line_type, DiffLineType::Addition);
1493 assert_eq!(hunk.lines[4].line_type, DiffLineType::Context);
1494 assert_eq!(hunk.lines[5].line_type, DiffLineType::Context);
1495 }
1496
1497 #[test]
1498 fn test_parse_unified_diff_line_numbers() {
1499 let diff = DiffData::from_unified_diff(SAMPLE_DIFF);
1500 let hunk = &diff.hunks[0];
1501
1502 assert_eq!(hunk.lines[0].old_line_num, Some(1));
1504 assert_eq!(hunk.lines[0].new_line_num, Some(1));
1505
1506 assert_eq!(hunk.lines[1].old_line_num, Some(2));
1508 assert_eq!(hunk.lines[1].new_line_num, None);
1509
1510 assert_eq!(hunk.lines[2].old_line_num, None);
1512 assert_eq!(hunk.lines[2].new_line_num, Some(2));
1513 }
1514
1515 #[test]
1516 fn test_diff_statistics() {
1517 let diff = DiffData::from_unified_diff(SAMPLE_DIFF);
1518
1519 assert_eq!(diff.total_additions(), 2);
1520 assert_eq!(diff.total_deletions(), 1);
1521 }
1522
1523 #[test]
1524 fn test_state_new() {
1525 let diff = DiffData::from_unified_diff(SAMPLE_DIFF);
1526 let state = DiffViewerState::new(diff);
1527
1528 assert_eq!(state.scroll_y, 0);
1529 assert_eq!(state.scroll_x, 0);
1530 assert_eq!(state.view_mode, DiffViewMode::Unified);
1531 assert_eq!(state.selected_hunk, Some(0));
1532 }
1533
1534 #[test]
1535 fn test_state_from_unified_diff() {
1536 let state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1537
1538 assert!(!state.diff.hunks.is_empty());
1539 assert_eq!(state.diff.total_additions(), 2);
1540 }
1541
1542 #[test]
1543 fn test_state_scroll() {
1544 let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1545
1546 assert_eq!(state.scroll_y, 0);
1547 state.scroll_down();
1548 assert_eq!(state.scroll_y, 1);
1549 state.scroll_up();
1550 assert_eq!(state.scroll_y, 0);
1551 state.scroll_up(); assert_eq!(state.scroll_y, 0);
1553 }
1554
1555 #[test]
1556 fn test_horizontal_scroll() {
1557 let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1558
1559 state.scroll_right();
1560 assert_eq!(state.scroll_x, 4);
1561 state.scroll_right();
1562 assert_eq!(state.scroll_x, 8);
1563 state.scroll_left();
1564 assert_eq!(state.scroll_x, 4);
1565 state.scroll_left();
1566 assert_eq!(state.scroll_x, 0);
1567 state.scroll_left(); assert_eq!(state.scroll_x, 0);
1569 }
1570
1571 #[test]
1572 fn test_page_navigation() {
1573 let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1574 state.visible_height = 2;
1575
1576 state.page_down();
1577 assert_eq!(state.scroll_y, 2);
1578 state.page_up();
1579 assert_eq!(state.scroll_y, 0);
1580 }
1581
1582 #[test]
1583 fn test_go_to_top_bottom() {
1584 let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1585 state.visible_height = 2;
1586
1587 state.go_to_bottom();
1588 assert!(state.scroll_y > 0);
1589
1590 state.go_to_top();
1591 assert_eq!(state.scroll_y, 0);
1592 }
1593
1594 #[test]
1595 fn test_view_mode_toggle() {
1596 let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1597
1598 assert_eq!(state.view_mode, DiffViewMode::Unified);
1599 state.toggle_view_mode();
1600 assert_eq!(state.view_mode, DiffViewMode::SideBySide);
1601 state.toggle_view_mode();
1602 assert_eq!(state.view_mode, DiffViewMode::Unified);
1603 }
1604
1605 #[test]
1606 fn test_hunk_navigation() {
1607 let multi_hunk_diff = r#"--- a/file.txt
1608+++ b/file.txt
1609@@ -1,3 +1,3 @@
1610 line 1
1611-old line 2
1612+new line 2
1613 line 3
1614@@ -10,3 +10,3 @@
1615 line 10
1616-old line 11
1617+new line 11
1618 line 12
1619"#;
1620 let mut state = DiffViewerState::from_unified_diff(multi_hunk_diff);
1621
1622 assert_eq!(state.selected_hunk, Some(0));
1623 state.next_hunk();
1624 assert_eq!(state.selected_hunk, Some(1));
1625 state.next_hunk(); assert_eq!(state.selected_hunk, Some(1));
1627 state.prev_hunk();
1628 assert_eq!(state.selected_hunk, Some(0));
1629 state.prev_hunk(); assert_eq!(state.selected_hunk, Some(0));
1631 }
1632
1633 #[test]
1634 fn test_search() {
1635 let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1636
1637 state.start_search();
1638 state.search.query = "added".to_string();
1639 state.update_search();
1640
1641 assert!(!state.search.matches.is_empty());
1642 }
1643
1644 #[test]
1645 fn test_search_next_prev() {
1646 let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1647
1648 state.search.query = "line".to_string();
1649 state.update_search();
1650
1651 let initial_match = state.search.current_match;
1652 state.next_match();
1653 assert_ne!(state.search.current_match, initial_match);
1654 state.prev_match();
1655 assert_eq!(state.search.current_match, initial_match);
1656 }
1657
1658 #[test]
1659 fn test_empty_state() {
1660 let state = DiffViewerState::empty();
1661 assert!(state.diff.hunks.is_empty());
1662 assert_eq!(state.selected_hunk, None);
1663 }
1664
1665 #[test]
1666 fn test_parse_hunk_header() {
1667 let result = parse_hunk_header("@@ -1,3 +1,4 @@");
1668 assert_eq!(result, Some((1, 3, 1, 4)));
1669
1670 let result = parse_hunk_header("@@ -10 +20,5 @@");
1671 assert_eq!(result, Some((10, 1, 20, 5)));
1672
1673 let result = parse_hunk_header("@@ -1,2 +3,4 @@ function name");
1674 assert_eq!(result, Some((1, 2, 3, 4)));
1675 }
1676
1677 #[test]
1678 fn test_diff_line_constructors() {
1679 let context = DiffLine::context("test".to_string(), 1, 2);
1680 assert_eq!(context.line_type, DiffLineType::Context);
1681 assert_eq!(context.old_line_num, Some(1));
1682 assert_eq!(context.new_line_num, Some(2));
1683
1684 let addition = DiffLine::addition("new".to_string(), 5);
1685 assert_eq!(addition.line_type, DiffLineType::Addition);
1686 assert_eq!(addition.new_line_num, Some(5));
1687 assert_eq!(addition.old_line_num, None);
1688
1689 let deletion = DiffLine::deletion("old".to_string(), 3);
1690 assert_eq!(deletion.line_type, DiffLineType::Deletion);
1691 assert_eq!(deletion.old_line_num, Some(3));
1692 assert_eq!(deletion.new_line_num, None);
1693 }
1694
1695 #[test]
1696 fn test_diff_hunk_counts() {
1697 let mut hunk = DiffHunk::new("@@ -1,3 +1,4 @@".to_string(), 1, 3, 1, 4);
1698 hunk.add_line(DiffLine::context("ctx".to_string(), 1, 1));
1699 hunk.add_line(DiffLine::deletion("del".to_string(), 2));
1700 hunk.add_line(DiffLine::addition("add1".to_string(), 2));
1701 hunk.add_line(DiffLine::addition("add2".to_string(), 3));
1702
1703 assert_eq!(hunk.addition_count(), 2);
1704 assert_eq!(hunk.deletion_count(), 1);
1705 }
1706
1707 #[test]
1708 fn test_style_default() {
1709 let style = DiffViewerStyle::default();
1710 assert_eq!(style.gutter_separator, "│");
1711 assert_eq!(style.side_separator, "│");
1712 }
1713
1714 #[test]
1715 fn test_key_handler_scroll() {
1716 let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1717
1718 let key_j = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
1719 assert!(handle_diff_viewer_key(&mut state, &key_j));
1720 assert_eq!(state.scroll_y, 1);
1721
1722 let key_k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
1723 assert!(handle_diff_viewer_key(&mut state, &key_k));
1724 assert_eq!(state.scroll_y, 0);
1725 }
1726
1727 #[test]
1728 fn test_key_handler_view_mode() {
1729 let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1730
1731 let key_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
1732 assert!(handle_diff_viewer_key(&mut state, &key_v));
1733 assert_eq!(state.view_mode, DiffViewMode::SideBySide);
1734 }
1735
1736 #[test]
1737 fn test_key_handler_search() {
1738 let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1739
1740 let key_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE);
1741 assert!(handle_diff_viewer_key(&mut state, &key_slash));
1742 assert!(state.search.active);
1743
1744 let key_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
1745 assert!(handle_diff_viewer_key(&mut state, &key_a));
1746 assert_eq!(state.search.query, "a");
1747
1748 let key_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
1749 assert!(handle_diff_viewer_key(&mut state, &key_esc));
1750 assert!(!state.search.active);
1751 }
1752
1753 #[test]
1754 fn test_render_does_not_panic() {
1755 let state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1756 let viewer = DiffViewer::new(&state).title("Test Diff");
1757
1758 let mut buf = Buffer::empty(Rect::new(0, 0, 80, 20));
1759 viewer.render(Rect::new(0, 0, 80, 20), &mut buf);
1760 }
1761
1762 #[test]
1763 fn test_render_side_by_side_does_not_panic() {
1764 let mut state = DiffViewerState::from_unified_diff(SAMPLE_DIFF);
1765 state.view_mode = DiffViewMode::SideBySide;
1766 let viewer = DiffViewer::new(&state);
1767
1768 let mut buf = Buffer::empty(Rect::new(0, 0, 120, 20));
1769 viewer.render(Rect::new(0, 0, 120, 20), &mut buf);
1770 }
1771}