1use crate::change::{Change, ChangeKind, ChangeSpan};
4use crate::diff::DiffResult;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
9pub enum StepDirection {
10 #[default]
11 None,
12 Forward,
13 Backward,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum AnimationFrame {
19 #[default]
20 Idle,
21 FadeOut,
22 FadeIn,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct StepState {
28 pub current_step: usize,
30 pub total_steps: usize,
32 pub applied_changes: Vec<usize>,
34 pub active_change: Option<usize>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub cursor_change: Option<usize>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub animating_hunk: Option<usize>,
42 pub step_direction: StepDirection,
44 pub current_hunk: usize,
46 pub total_hunks: usize,
48 #[serde(default)]
50 pub last_nav_was_hunk: bool,
51 #[serde(default)]
53 pub hunk_preview_mode: bool,
54 #[serde(default)]
56 pub preview_from_backward: bool,
57 #[serde(default)]
59 pub show_hunk_extent_while_stepping: bool,
60}
61
62impl StepState {
63 pub fn new(total_changes: usize, total_hunks: usize) -> Self {
64 Self {
65 current_step: 0,
66 total_steps: total_changes + 1, applied_changes: Vec::new(),
68 active_change: None,
69 cursor_change: None,
70 animating_hunk: None,
71 step_direction: StepDirection::None,
72 current_hunk: 0,
73 total_hunks,
74 last_nav_was_hunk: false,
75 hunk_preview_mode: false,
76 preview_from_backward: false,
77 show_hunk_extent_while_stepping: false,
78 }
79 }
80
81 pub fn is_at_start(&self) -> bool {
83 self.current_step == 0
84 }
85
86 pub fn is_at_end(&self) -> bool {
88 self.current_step >= self.total_steps - 1
89 }
90
91 pub fn progress(&self) -> f64 {
93 if self.total_steps <= 1 {
94 return 100.0;
95 }
96 (self.current_step as f64 / (self.total_steps - 1) as f64) * 100.0
97 }
98}
99
100pub struct DiffNavigator {
102 diff: DiffResult,
104 state: StepState,
106 old_content: String,
108 new_content: String,
110 change_to_hunk: std::collections::HashMap<usize, usize>,
112}
113
114impl DiffNavigator {
115 pub fn new(diff: DiffResult, old_content: String, new_content: String) -> Self {
116 let total_changes = diff.significant_changes.len();
117 let total_hunks = diff.hunks.len();
118
119 let mut change_to_hunk = std::collections::HashMap::new();
121 for (hunk_idx, hunk) in diff.hunks.iter().enumerate() {
122 for &change_id in &hunk.change_ids {
123 change_to_hunk.insert(change_id, hunk_idx);
124 }
125 }
126
127 Self {
128 diff,
129 state: StepState::new(total_changes, total_hunks),
130 old_content,
131 new_content,
132 change_to_hunk,
133 }
134 }
135
136 pub fn state(&self) -> &StepState {
138 &self.state
139 }
140
141 #[cfg(test)]
143 pub fn state_mut(&mut self) -> &mut StepState {
144 &mut self.state
145 }
146
147 pub fn set_state(&mut self, state: StepState) -> bool {
149 if state.total_steps != self.state.total_steps
150 || state.total_hunks != self.state.total_hunks
151 {
152 return false;
153 }
154 self.state = state;
155 true
156 }
157
158 pub fn diff(&self) -> &DiffResult {
160 &self.diff
161 }
162
163 pub fn set_cursor_hunk(&mut self, hunk_idx: usize, change_id: Option<usize>) {
165 if self.state.total_hunks > 0 {
166 self.state.current_hunk = hunk_idx.min(self.state.total_hunks - 1);
167 }
168 self.state.cursor_change = change_id;
169 }
170
171 pub fn clear_cursor_change(&mut self) {
173 self.state.cursor_change = None;
174 }
175
176 pub fn set_hunk_scope(&mut self, enabled: bool) {
178 self.state.last_nav_was_hunk = enabled;
179 }
180
181 #[allow(clippy::should_implement_trait)]
183 pub fn next(&mut self) -> bool {
184 if self.state.hunk_preview_mode {
186 return self.dissolve_preview_for_step_down();
187 }
188
189 if self.state.is_at_end() {
190 return false;
191 }
192
193 let prev_hunk = self.state.current_hunk;
194
195 self.state.step_direction = StepDirection::Forward;
196 self.state.animating_hunk = None; let change_idx = self.state.current_step;
200 if change_idx < self.diff.significant_changes.len() {
201 let change_id = self.diff.significant_changes[change_idx];
202 self.state.applied_changes.push(change_id);
203 self.state.active_change = Some(change_id);
204
205 if let Some(hunk) = self.diff.hunk_for_change(change_id) {
207 self.state.current_hunk = hunk.id;
208 }
209 }
210
211 self.state.current_step += 1;
212
213 if self.state.current_hunk != prev_hunk {
215 self.state.last_nav_was_hunk = false;
216 }
217
218 true
219 }
220
221 fn dissolve_preview_for_step_down(&mut self) -> bool {
223 if self.state.preview_from_backward {
224 self.state.hunk_preview_mode = false;
225 self.state.preview_from_backward = false;
226 return self.next();
227 }
228
229 let hunk = &self.diff.hunks[self.state.current_hunk];
230
231 if hunk.change_ids.len() <= 1 {
233 self.state.hunk_preview_mode = false;
234 return self.next();
236 }
237
238 let first_change = hunk.change_ids[0];
240 let second_change = hunk.change_ids[1];
241
242 self.state
244 .applied_changes
245 .retain(|&id| !hunk.change_ids.contains(&id) || id == first_change);
246
247 self.state.applied_changes.push(second_change);
249
250 self.state.current_step = self.state.applied_changes.len();
252
253 self.state.active_change = Some(second_change);
254 self.state.step_direction = StepDirection::Forward;
255 self.state.hunk_preview_mode = false;
256 self.state.preview_from_backward = false;
257 self.state.animating_hunk = None;
258 self.state.last_nav_was_hunk = true;
260
261 true
262 }
263
264 pub fn prev(&mut self) -> bool {
266 if self.state.hunk_preview_mode {
268 return self.dissolve_preview_for_step_up();
269 }
270
271 if self.state.is_at_start() {
272 return false;
273 }
274
275 let prev_hunk = self.state.current_hunk;
276
277 self.state.step_direction = StepDirection::Backward;
278 self.state.animating_hunk = None; self.state.current_step -= 1;
280
281 if let Some(unapplied_change_id) = self.state.applied_changes.pop() {
283 self.state.active_change = Some(unapplied_change_id);
284
285 if let Some(&last_applied) = self.state.applied_changes.last() {
287 if let Some(hunk) = self.diff.hunk_for_change(last_applied) {
288 self.state.current_hunk = hunk.id;
289 }
290 } else {
291 self.state.current_hunk = 0;
292 }
293 } else {
294 self.state.active_change = None;
295 }
296
297 if self.state.is_at_start() {
299 self.state.current_hunk = 0;
300 }
301
302 if self.state.is_at_start() || self.state.current_hunk != prev_hunk {
304 self.state.last_nav_was_hunk = false;
305 }
306
307 true
308 }
309
310 fn dissolve_preview_for_step_up(&mut self) -> bool {
312 if self.state.preview_from_backward {
313 self.state.hunk_preview_mode = false;
314 self.state.preview_from_backward = false;
315 return self.prev();
316 }
317
318 let current_hunk_idx = self.state.current_hunk;
319 let hunk = &self.diff.hunks[current_hunk_idx];
320
321 self.state.animating_hunk = Some(current_hunk_idx);
323
324 for &change_id in &hunk.change_ids {
326 self.state.applied_changes.retain(|&id| id != change_id);
327 }
328
329 self.state.current_step = self.state.applied_changes.len();
331
332 if current_hunk_idx > 0 {
334 self.state.current_hunk = current_hunk_idx - 1;
335 }
336
337 self.state.step_direction = StepDirection::Backward;
338 self.state.active_change = self.state.applied_changes.last().copied();
340 self.state.hunk_preview_mode = false;
341 self.state.preview_from_backward = false;
342 self.state.last_nav_was_hunk = false; true
345 }
346
347 pub fn clear_active_change(&mut self) {
350 if self.state.step_direction == StepDirection::Backward {
351 self.state.active_change = self.state.applied_changes.last().copied();
352 } else {
353 self.state.active_change = None;
354 }
355 self.state.animating_hunk = None;
356 self.state.step_direction = StepDirection::None;
357 }
358
359 pub fn goto(&mut self, step: usize) {
361 let target_step = step.min(self.state.total_steps - 1);
362
363 self.state.current_step = 0;
365 self.state.applied_changes.clear();
366 self.state.active_change = None;
367 self.state.cursor_change = None;
368 self.state.animating_hunk = None;
369 self.state.current_hunk = 0;
370 self.state.last_nav_was_hunk = false; self.state.hunk_preview_mode = false; self.state.preview_from_backward = false;
373
374 for _ in 0..target_step {
376 self.next();
377 }
378
379 self.update_current_hunk();
381 }
382
383 pub fn goto_start(&mut self) {
385 self.goto(0);
386 }
387
388 pub fn goto_end(&mut self) {
390 self.goto(self.state.total_steps - 1);
391 }
392
393 pub fn next_hunk(&mut self) -> bool {
400 if self.diff.hunks.is_empty() {
401 return false;
402 }
403
404 let was_in_preview = self.state.hunk_preview_mode;
406
407 self.state.step_direction = StepDirection::Forward;
408 self.state.hunk_preview_mode = false; self.state.preview_from_backward = false;
410
411 let current_hunk = &self.diff.hunks[self.state.current_hunk];
412 let has_applied_in_current = current_hunk
413 .change_ids
414 .iter()
415 .any(|id| self.state.applied_changes.contains(id));
416
417 if !has_applied_in_current {
419 let mut moved = false;
420 for &change_id in ¤t_hunk.change_ids {
421 if !self.state.applied_changes.contains(&change_id) {
422 self.state.applied_changes.push(change_id);
423 self.state.current_step += 1;
424 moved = true;
425 }
426 }
427
428 self.state.animating_hunk = Some(self.state.current_hunk);
429 self.state.active_change = current_hunk.change_ids.first().copied();
430
431 if moved {
432 self.state.last_nav_was_hunk = true;
433 self.state.hunk_preview_mode = true;
434 self.state.preview_from_backward = false;
435 }
436
437 return moved;
438 }
439
440 self.state.hunk_preview_mode = false;
442 self.state.preview_from_backward = false;
443 let mut completed_any = false;
444 for &change_id in ¤t_hunk.change_ids {
445 if !self.state.applied_changes.contains(&change_id) {
446 self.state.applied_changes.push(change_id);
447 self.state.current_step += 1;
448 completed_any = true;
449 }
450 }
451
452 let next_hunk_idx = self.state.current_hunk + 1;
454 if next_hunk_idx >= self.diff.hunks.len() {
455 if completed_any {
457 self.state.animating_hunk = Some(self.state.current_hunk);
458 self.state.active_change = current_hunk.change_ids.last().copied();
459 self.state.last_nav_was_hunk = true;
460 return true;
461 }
462 self.state.hunk_preview_mode = was_in_preview;
464 return false;
465 }
466
467 let hunk = &self.diff.hunks[next_hunk_idx];
468
469 let mut moved = false;
471 for &change_id in &hunk.change_ids {
472 if !self.state.applied_changes.contains(&change_id) {
473 self.state.applied_changes.push(change_id);
474 self.state.current_step += 1;
475 moved = true;
476 }
477 }
478
479 self.state.animating_hunk = Some(next_hunk_idx);
480 self.state.active_change = hunk.change_ids.first().copied();
481 self.state.current_hunk = next_hunk_idx;
482
483 if moved {
484 self.state.last_nav_was_hunk = true;
485 self.state.hunk_preview_mode = true;
486 self.state.preview_from_backward = false;
487 }
488
489 moved
490 }
491
492 pub fn prev_hunk(&mut self) -> bool {
495 if self.diff.hunks.is_empty() {
496 return false;
497 }
498
499 let was_in_preview = self.state.hunk_preview_mode;
501
502 self.state.hunk_preview_mode = false;
504 self.state.preview_from_backward = false;
505
506 if self.state.current_hunk == 0 {
508 let hunk = &self.diff.hunks[0];
509 let has_applied = hunk
510 .change_ids
511 .iter()
512 .any(|id| self.state.applied_changes.contains(id));
513 if !has_applied {
514 self.state.hunk_preview_mode = was_in_preview;
516 return false;
517 }
518 }
519
520 self.state.step_direction = StepDirection::Backward;
521
522 let current_hunk_idx = self.state.current_hunk;
524 let current_hunk = &self.diff.hunks[current_hunk_idx];
525 let mut moved = false;
526
527 for &change_id in current_hunk.change_ids.iter().rev() {
529 if let Some(pos) = self
530 .state
531 .applied_changes
532 .iter()
533 .position(|&id| id == change_id)
534 {
535 self.state.applied_changes.remove(pos);
536 self.state.current_step = self.state.current_step.saturating_sub(1);
537 moved = true;
538 }
539 }
540
541 self.state.animating_hunk = Some(current_hunk_idx);
544 self.state.active_change = current_hunk.change_ids.first().copied();
545
546 if moved {
549 let still_has_applied = current_hunk
551 .change_ids
552 .iter()
553 .any(|id| self.state.applied_changes.contains(id));
554 if !still_has_applied && self.state.current_hunk > 0 {
555 self.state.current_hunk -= 1;
556 }
557 } else if self.state.current_hunk > 0 {
558 self.state.current_hunk -= 1;
560 return self.prev_hunk();
561 }
562
563 if moved {
570 let entered_prev_hunk = self.state.current_hunk != current_hunk_idx;
571 if entered_prev_hunk {
572 self.state.hunk_preview_mode = true;
573 self.state.preview_from_backward = true;
574 self.state.last_nav_was_hunk = true;
575 } else {
576 self.state.last_nav_was_hunk = !self.state.is_at_start();
578 }
579 }
580
581 moved
582 }
583
584 pub fn goto_hunk(&mut self, hunk_idx: usize) {
588 if hunk_idx >= self.diff.hunks.len() {
589 return;
590 }
591
592 self.goto_start();
594
595 for idx in 0..hunk_idx {
597 let hunk = &self.diff.hunks[idx];
598 for &change_id in &hunk.change_ids {
599 self.state.applied_changes.push(change_id);
600 self.state.current_step += 1;
601 }
602 }
603
604 let hunk = &self.diff.hunks[hunk_idx];
606 for &change_id in &hunk.change_ids {
607 self.state.applied_changes.push(change_id);
608 self.state.current_step += 1;
609 }
610
611 self.state.current_hunk = hunk_idx;
612 self.state.animating_hunk = Some(hunk_idx);
613 self.state.active_change = hunk.change_ids.first().copied();
614 self.state.step_direction = StepDirection::Forward;
615 self.state.last_nav_was_hunk = true;
616 self.state.hunk_preview_mode = true;
617 self.state.preview_from_backward = false;
618 }
619
620 pub fn goto_hunk_start(&mut self) -> bool {
623 if self.diff.hunks.is_empty() {
624 return false;
625 }
626
627 let hunk = &self.diff.hunks[self.state.current_hunk];
628 let first_change = match hunk.change_ids.first() {
629 Some(&id) => id,
630 None => return false,
631 };
632
633 if !self.state.applied_changes.contains(&first_change) {
635 return false;
636 }
637
638 let mut unapplied_any = false;
640 for &change_id in &hunk.change_ids[1..] {
641 if let Some(pos) = self
642 .state
643 .applied_changes
644 .iter()
645 .position(|&id| id == change_id)
646 {
647 self.state.applied_changes.remove(pos);
648 self.state.current_step = self.state.current_step.saturating_sub(1);
649 unapplied_any = true;
650 }
651 }
652
653 if !unapplied_any && self.state.active_change == Some(first_change) {
655 return false;
656 }
657
658 self.state.active_change = Some(first_change);
659 self.state.hunk_preview_mode = false;
660 self.state.preview_from_backward = false;
661 self.state.last_nav_was_hunk = true;
662 true
663 }
664
665 pub fn goto_hunk_end(&mut self) -> bool {
668 if self.diff.hunks.is_empty() {
669 return false;
670 }
671
672 let hunk = &self.diff.hunks[self.state.current_hunk];
673 let has_applied = hunk
674 .change_ids
675 .iter()
676 .any(|id| self.state.applied_changes.contains(id));
677 if !has_applied {
678 return false;
679 }
680
681 let last_change = hunk.change_ids.last().copied();
682
683 for &change_id in &hunk.change_ids {
685 if !self.state.applied_changes.contains(&change_id) {
686 self.state.applied_changes.push(change_id);
687 self.state.current_step += 1;
688 }
689 }
690
691 if self.state.active_change == last_change {
693 return false;
694 }
695
696 self.state.active_change = last_change;
697 self.state.hunk_preview_mode = false;
698 self.state.preview_from_backward = false;
699 self.state.last_nav_was_hunk = true;
700 true
701 }
702
703 pub fn update_current_hunk(&mut self) {
705 if self.diff.hunks.is_empty() {
706 return;
707 }
708
709 if let Some(&last_applied) = self.state.applied_changes.last() {
711 for (idx, hunk) in self.diff.hunks.iter().enumerate() {
712 if hunk.change_ids.contains(&last_applied) {
713 self.state.current_hunk = idx;
714 return;
715 }
716 }
717 }
718
719 self.state.current_hunk = 0;
721 }
722
723 pub fn current_hunk(&self) -> Option<&crate::diff::Hunk> {
725 self.diff.hunks.get(self.state.current_hunk)
726 }
727
728 pub fn hunks(&self) -> &[crate::diff::Hunk] {
730 &self.diff.hunks
731 }
732
733 pub fn set_show_hunk_extent_while_stepping(&mut self, enabled: bool) {
734 self.state.show_hunk_extent_while_stepping = enabled;
735 }
736
737 fn is_change_in_animating_hunk(&self, change_id: usize) -> bool {
741 if let Some(hunk_idx) = self.state.animating_hunk {
742 if let Some(hunk) = self.diff.hunks.get(hunk_idx) {
743 return hunk.change_ids.contains(&change_id);
744 }
745 }
746 false
747 }
748
749 fn is_change_in_current_hunk(&self, change_id: usize) -> bool {
751 self.diff
752 .hunks
753 .get(self.state.current_hunk)
754 .map(|hunk| hunk.change_ids.contains(&change_id))
755 .unwrap_or(false)
756 }
757
758 pub fn active_change(&self) -> Option<&Change> {
760 self.state
761 .active_change
762 .and_then(|id| self.diff.changes.iter().find(|c| c.id == id))
763 }
764
765 pub fn changes_with_status(&self) -> Vec<(&Change, bool, bool)> {
767 self.diff
768 .changes
769 .iter()
770 .filter(|c| c.has_changes())
771 .map(|c| {
772 let applied = self.state.applied_changes.contains(&c.id);
773 let active = self.state.active_change == Some(c.id);
774 (c, applied, active)
775 })
776 .collect()
777 }
778
779 pub fn current_view(&self) -> Vec<ViewLine> {
782 self.current_view_with_frame(AnimationFrame::Idle)
783 }
784
785 pub fn current_view_with_frame(&self, frame: AnimationFrame) -> Vec<ViewLine> {
788 let mut lines = Vec::new();
789
790 let primary_change_id = if self.state.cursor_change.is_some()
793 && self.state.active_change.is_none()
794 && self.state.step_direction == StepDirection::None
795 {
796 self.state.cursor_change
797 } else if self.state.step_direction == StepDirection::Backward {
798 self.state
799 .applied_changes
800 .last()
801 .copied()
802 .or(self.state.active_change)
803 } else {
804 self.state.active_change
805 };
806
807 let mut primary_assigned = false;
809
810 for change in &self.diff.changes {
811 let is_applied = self.state.applied_changes.contains(&change.id);
812
813 let is_primary_active = primary_change_id == Some(change.id);
815
816 let is_in_hunk = self.is_change_in_animating_hunk(change.id);
818 let is_active_change = self.state.active_change == Some(change.id);
819 let is_active = is_active_change || is_in_hunk;
821 let show_hunk_extent = is_in_hunk
823 || (self.is_change_in_current_hunk(change.id)
824 && (self.state.last_nav_was_hunk
825 || self.state.show_hunk_extent_while_stepping));
826
827 let is_primary_active = is_primary_active
830 || (!primary_assigned && is_in_hunk && primary_change_id.is_none());
831
832 if is_primary_active {
833 primary_assigned = true;
834 }
835
836 let is_word_level = change.spans.len() > 1;
838
839 if is_word_level {
840 let line = self.build_word_level_line(
842 change,
843 is_applied,
844 is_active,
845 is_active_change,
846 is_primary_active,
847 show_hunk_extent,
848 frame,
849 );
850 if let Some(l) = line {
851 lines.push(l);
852 }
853 } else {
854 if let Some(span) = change.spans.first() {
856 if let Some(line) = self.build_single_span_line(
857 span,
858 change.id,
859 is_applied,
860 is_active,
861 is_active_change,
862 is_primary_active,
863 show_hunk_extent,
864 frame,
865 ) {
866 lines.push(line);
867 }
868 }
869 }
870 }
871
872 lines
873 }
874
875 fn compute_show_new(&self, is_applied: bool, frame: AnimationFrame) -> bool {
878 if self.state.step_direction == StepDirection::None {
880 return is_applied;
881 }
882 match frame {
883 AnimationFrame::Idle => is_applied,
884 AnimationFrame::FadeOut => self.state.step_direction == StepDirection::Backward,
886 AnimationFrame::FadeIn => self.state.step_direction != StepDirection::Backward,
888 }
889 }
890
891 #[allow(clippy::too_many_arguments)]
892 fn build_word_level_line(
893 &self,
894 change: &Change,
895 is_applied: bool,
896 is_active: bool,
897 is_active_change: bool,
898 is_primary_active: bool,
899 show_hunk_extent: bool,
900 frame: AnimationFrame,
901 ) -> Option<ViewLine> {
902 let first_span = change.spans.first()?;
903 let old_line = first_span.old_line;
904 let new_line = first_span.new_line;
905
906 let has_old = change
909 .spans
910 .iter()
911 .any(|s| matches!(s.kind, ChangeKind::Delete | ChangeKind::Replace));
912 let has_new = change
913 .spans
914 .iter()
915 .any(|s| matches!(s.kind, ChangeKind::Insert | ChangeKind::Replace));
916
917 let mut view_spans = Vec::new();
919 let mut content = String::new();
920
921 for span in &change.spans {
922 let (span_kind, text) = if is_active {
924 let show_new = match frame {
931 AnimationFrame::Idle => self.compute_show_new(is_applied, frame),
932 _ => {
933 if has_old && has_new {
934 self.compute_show_new(is_applied, frame)
935 } else if has_new {
936 true } else {
938 false }
940 }
941 };
942
943 match span.kind {
944 ChangeKind::Equal => (ViewSpanKind::Equal, span.text.clone()),
945 ChangeKind::Delete => {
946 if show_new {
947 continue; } else {
949 (ViewSpanKind::PendingDelete, span.text.clone())
950 }
951 }
952 ChangeKind::Insert => {
953 if show_new {
954 (ViewSpanKind::PendingInsert, span.text.clone())
955 } else {
956 continue; }
958 }
959 ChangeKind::Replace => {
960 if show_new {
961 (
962 ViewSpanKind::PendingInsert,
963 span.new_text.clone().unwrap_or_else(|| span.text.clone()),
964 )
965 } else {
966 (ViewSpanKind::PendingDelete, span.text.clone())
967 }
968 }
969 }
970 } else if is_applied {
971 let kind = match span.kind {
973 ChangeKind::Equal => ViewSpanKind::Equal,
974 ChangeKind::Delete => ViewSpanKind::Deleted,
975 ChangeKind::Insert => ViewSpanKind::Inserted,
976 ChangeKind::Replace => ViewSpanKind::Inserted,
977 };
978 let text = match span.kind {
979 ChangeKind::Delete => {
980 continue; }
982 ChangeKind::Replace => {
983 span.new_text.clone().unwrap_or_else(|| span.text.clone())
984 }
985 _ => span.text.clone(),
986 };
987 (kind, text)
988 } else {
989 match span.kind {
991 ChangeKind::Insert => {
992 continue; }
994 _ => (ViewSpanKind::Equal, span.text.clone()),
995 }
996 };
997
998 content.push_str(&text);
999 view_spans.push(ViewSpan {
1000 text,
1001 kind: span_kind,
1002 });
1003 }
1004
1005 if view_spans.is_empty() {
1007 return None;
1008 }
1009
1010 let line_kind = if is_active {
1013 LineKind::PendingModify
1014 } else if is_applied {
1015 LineKind::Modified
1016 } else {
1017 LineKind::Context
1018 };
1019
1020 let hunk_index = self.change_to_hunk.get(&change.id).copied();
1022 let has_changes = change.has_changes();
1023
1024 Some(ViewLine {
1025 content,
1026 spans: view_spans,
1027 kind: line_kind,
1028 old_line,
1029 new_line,
1030 is_active,
1031 is_active_change,
1032 is_primary_active,
1033 show_hunk_extent,
1034 change_id: change.id,
1035 hunk_index,
1036 has_changes,
1037 })
1038 }
1039
1040 #[allow(clippy::too_many_arguments)]
1041 fn build_single_span_line(
1042 &self,
1043 span: &ChangeSpan,
1044 change_id: usize,
1045 is_applied: bool,
1046 is_active: bool,
1047 is_active_change: bool,
1048 is_primary_active: bool,
1049 show_hunk_extent: bool,
1050 frame: AnimationFrame,
1051 ) -> Option<ViewLine> {
1052 let view_span_kind;
1053 let line_kind;
1054 let content;
1055
1056 match span.kind {
1057 ChangeKind::Equal => {
1058 view_span_kind = ViewSpanKind::Equal;
1059 line_kind = LineKind::Context;
1060 content = span.text.clone();
1061 }
1062 ChangeKind::Delete => {
1063 if is_active {
1065 view_span_kind = ViewSpanKind::PendingDelete;
1066 line_kind = LineKind::PendingDelete;
1067 content = span.text.clone();
1068 } else if is_applied {
1069 view_span_kind = ViewSpanKind::Deleted;
1070 line_kind = LineKind::Deleted;
1071 content = span.text.clone();
1072 } else {
1073 view_span_kind = ViewSpanKind::Equal;
1074 line_kind = LineKind::Context;
1075 content = span.text.clone();
1076 }
1077 }
1078 ChangeKind::Insert => {
1079 if is_active {
1081 view_span_kind = ViewSpanKind::PendingInsert;
1082 line_kind = LineKind::PendingInsert;
1083 content = span.text.clone();
1084 } else if is_applied {
1085 view_span_kind = ViewSpanKind::Inserted;
1086 line_kind = LineKind::Inserted;
1087 content = span.text.clone();
1088 } else {
1089 return None; }
1091 }
1092 ChangeKind::Replace => {
1093 if is_active {
1095 let show_new = self.compute_show_new(is_applied, frame);
1096 if show_new {
1097 view_span_kind = ViewSpanKind::PendingInsert;
1098 content = span.new_text.clone().unwrap_or_else(|| span.text.clone());
1099 } else {
1100 view_span_kind = ViewSpanKind::PendingDelete;
1101 content = span.text.clone();
1102 }
1103 line_kind = LineKind::PendingModify;
1104 } else if is_applied {
1105 view_span_kind = ViewSpanKind::Inserted;
1106 line_kind = LineKind::Modified;
1107 content = span.new_text.clone().unwrap_or_else(|| span.text.clone());
1108 } else {
1109 view_span_kind = ViewSpanKind::Equal;
1110 line_kind = LineKind::Context;
1111 content = span.text.clone();
1112 }
1113 }
1114 }
1115
1116 let hunk_index = self.change_to_hunk.get(&change_id).copied();
1118 let has_changes = !matches!(span.kind, ChangeKind::Equal);
1119
1120 Some(ViewLine {
1121 content: content.clone(),
1122 spans: vec![ViewSpan {
1123 text: content,
1124 kind: view_span_kind,
1125 }],
1126 kind: line_kind,
1127 old_line: span.old_line,
1128 new_line: span.new_line,
1129 is_active,
1130 is_active_change,
1131 is_primary_active,
1132 show_hunk_extent,
1133 change_id,
1134 hunk_index,
1135 has_changes,
1136 })
1137 }
1138
1139 pub fn old_content(&self) -> &str {
1141 &self.old_content
1142 }
1143
1144 pub fn new_content(&self) -> &str {
1146 &self.new_content
1147 }
1148}
1149
1150#[derive(Debug, Clone)]
1152pub struct ViewSpan {
1153 pub text: String,
1154 pub kind: ViewSpanKind,
1155}
1156
1157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1159pub enum ViewSpanKind {
1160 Equal,
1161 Inserted,
1162 Deleted,
1163 PendingInsert,
1164 PendingDelete,
1165}
1166
1167#[derive(Debug, Clone)]
1169pub struct ViewLine {
1170 pub content: String,
1172 pub spans: Vec<ViewSpan>,
1174 pub kind: LineKind,
1176 pub old_line: Option<usize>,
1177 pub new_line: Option<usize>,
1178 pub is_active: bool,
1180 pub is_active_change: bool,
1182 pub is_primary_active: bool,
1184 pub show_hunk_extent: bool,
1186 pub change_id: usize,
1188 pub hunk_index: Option<usize>,
1190 pub has_changes: bool,
1192}
1193
1194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1196pub enum LineKind {
1197 Context,
1199 Inserted,
1201 Deleted,
1203 Modified,
1205 PendingDelete,
1207 PendingInsert,
1209 PendingModify,
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215 use super::*;
1216 use crate::diff::DiffEngine;
1217
1218 #[test]
1219 fn test_navigation() {
1220 let old = "foo\nbar\nbaz";
1221 let new = "foo\nqux\nbaz";
1222
1223 let engine = DiffEngine::new();
1224 let diff = engine.diff_strings(old, new);
1225 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1226
1227 assert!(nav.state().is_at_start());
1228 assert!(!nav.state().is_at_end());
1229
1230 nav.next();
1231 assert!(!nav.state().is_at_start());
1232
1233 nav.goto_end();
1234 assert!(nav.state().is_at_end());
1235
1236 nav.prev();
1237 assert!(!nav.state().is_at_end());
1238
1239 nav.goto_start();
1240 assert!(nav.state().is_at_start());
1241 }
1242
1243 #[test]
1244 fn test_progress() {
1245 let old = "a\nb\nc\nd";
1246 let new = "a\nB\nC\nd";
1247
1248 let engine = DiffEngine::new();
1249 let diff = engine.diff_strings(old, new);
1250 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1251
1252 assert_eq!(nav.state().progress(), 0.0);
1253
1254 nav.goto_end();
1255 assert_eq!(nav.state().progress(), 100.0);
1256 }
1257
1258 #[test]
1259 fn test_word_level_view() {
1260 let old = "const foo = 4";
1261 let new = "const bar = 5";
1262
1263 let engine = DiffEngine::new().with_word_level(true);
1264 let diff = engine.diff_strings(old, new);
1265 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1266
1267 let view = nav.current_view();
1269 assert_eq!(view.len(), 1);
1270 assert_eq!(view[0].content, "const foo = 4");
1271
1272 nav.next();
1274 let view = nav.current_view();
1275 assert_eq!(view.len(), 1);
1276 assert_eq!(view[0].content, "const bar = 5");
1277 }
1278
1279 #[test]
1280 fn test_prev_hunk_animation_state() {
1281 let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl";
1284 let new = "a\nB\nc\nd\ne\nf\ng\nh\ni\nj\nK\nl";
1285 let engine = DiffEngine::new();
1288 let diff = engine.diff_strings(old, new);
1289
1290 assert!(
1292 diff.hunks.len() >= 2,
1293 "Expected at least 2 hunks, got {}. Adjust fixture gap.",
1294 diff.hunks.len()
1295 );
1296
1297 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1298
1299 nav.next_hunk();
1301 nav.next_hunk();
1302 assert_eq!(nav.state().current_hunk, 1);
1303
1304 nav.prev_hunk();
1306
1307 assert_eq!(
1309 nav.state().animating_hunk,
1310 Some(1),
1311 "animating_hunk should stay on the hunk being removed for fade animation"
1312 );
1313 assert_eq!(
1315 nav.state().current_hunk,
1316 0,
1317 "current_hunk should move to destination for status display"
1318 );
1319
1320 let view = nav.current_view();
1322 let active_lines: Vec<_> = view.iter().filter(|l| l.is_active).collect();
1323 assert!(
1324 !active_lines.is_empty(),
1325 "Hunk changes should be marked active during fade"
1326 );
1327
1328 nav.clear_active_change();
1330 assert_eq!(
1331 nav.state().animating_hunk,
1332 None,
1333 "animating_hunk should be cleared after animation completes"
1334 );
1335 }
1336
1337 #[test]
1338 fn test_prev_to_start_preserves_animation_state() {
1339 let old = "a\nb\nc";
1342 let new = "a\nB\nc";
1343
1344 let engine = DiffEngine::new();
1345 let diff = engine.diff_strings(old, new);
1346 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1347
1348 nav.next();
1350 assert_eq!(nav.state().current_step, 1);
1351
1352 nav.prev();
1354 assert!(nav.state().is_at_start());
1355
1356 assert!(
1358 nav.state().active_change.is_some(),
1359 "active_change preserved for fade-out"
1360 );
1361 assert_eq!(
1362 nav.state().step_direction,
1363 StepDirection::Backward,
1364 "step_direction should be Backward"
1365 );
1366
1367 nav.clear_active_change();
1369 assert_eq!(nav.state().active_change, None);
1370 assert_eq!(nav.state().animating_hunk, None);
1371 assert_eq!(nav.state().step_direction, StepDirection::None);
1372 }
1373
1374 #[test]
1375 fn test_prev_hunk_from_hunk_0_unapplies_changes() {
1376 let old = "a\nb\nc";
1378 let new = "a\nB\nc";
1379
1380 let engine = DiffEngine::new();
1381 let diff = engine.diff_strings(old, new);
1382 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1383
1384 nav.next_hunk();
1386 assert_eq!(nav.state().current_step, 1);
1387 assert_eq!(nav.state().current_hunk, 0);
1388
1389 let moved = nav.prev_hunk();
1391 assert!(
1392 moved,
1393 "prev_hunk should succeed when hunk 0 has applied changes"
1394 );
1395 assert!(nav.state().is_at_start());
1396 assert_eq!(nav.state().current_step, 0);
1397
1398 assert_eq!(
1400 nav.state().animating_hunk,
1401 Some(0),
1402 "animating_hunk should point to hunk 0 for fade animation"
1403 );
1404 assert_eq!(nav.state().step_direction, StepDirection::Backward);
1405
1406 let moved_again = nav.prev_hunk();
1408 assert!(
1409 !moved_again,
1410 "prev_hunk should fail when hunk 0 has no applied changes"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_backward_primary_marker_on_destination() {
1416 let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1419 let new = "line1\nLINE2\nline3\nline4\nline5\nline6\nLINE7\nline8";
1420
1421 let engine = DiffEngine::new();
1422 let diff = engine.diff_strings(old, new);
1423 assert!(diff.hunks.len() >= 2, "Fixture must produce 2 hunks");
1424
1425 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1426
1427 nav.next_hunk(); nav.next_hunk(); assert_eq!(nav.state().current_step, 2);
1431
1432 nav.prev_hunk();
1434
1435 let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1436
1437 let primary_lines: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
1439 let active_lines: Vec<_> = view.iter().filter(|l| l.is_active).collect();
1440
1441 assert_eq!(primary_lines.len(), 1, "exactly one primary line");
1443
1444 assert!(active_lines.len() >= 1, "fading line should be active");
1446
1447 let primary = primary_lines[0];
1449 assert!(
1450 primary.content.contains("LINE2"),
1451 "primary marker should be on destination (hunk 0)"
1452 );
1453 assert!(
1454 !primary.content.contains("LINE7"),
1455 "primary marker should not be on fading line (hunk 1)"
1456 );
1457 }
1458
1459 #[test]
1460 fn test_forward_primary_marker_on_first_change() {
1461 let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1464 let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nline7\nline8";
1465 let engine = DiffEngine::new();
1468 let diff = engine.diff_strings(old, new);
1469 assert!(
1470 !diff.hunks.is_empty(),
1471 "Fixture must produce at least 1 hunk"
1472 );
1473 assert!(
1474 diff.hunks[0].change_ids.len() >= 2,
1475 "Hunk 0 must have at least 2 changes for this test"
1476 );
1477
1478 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1479
1480 nav.next_hunk();
1482
1483 let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1485
1486 let primary_lines: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
1487 assert_eq!(primary_lines.len(), 1, "exactly one primary line");
1488
1489 let primary = primary_lines[0];
1491 assert!(
1492 primary.content.contains("LINE2"),
1493 "primary marker should be on first change (LINE2), got: {}",
1494 primary.content
1495 );
1496 }
1497
1498 #[test]
1499 fn test_step_down_after_next_hunk() {
1500 let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1502 let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nline7\nline8";
1503
1504 let engine = DiffEngine::new();
1505 let diff = engine.diff_strings(old, new);
1506 assert!(
1507 diff.hunks[0].change_ids.len() >= 2,
1508 "Hunk 0 must have at least 2 changes"
1509 );
1510
1511 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1512
1513 nav.next_hunk();
1515 assert_eq!(
1516 nav.state().current_step,
1517 2,
1518 "Should apply all 2 changes after next_hunk"
1519 );
1520 assert!(nav.state().hunk_preview_mode, "Should be in preview mode");
1521
1522 let moved = nav.next();
1524 assert!(moved, "next() should succeed");
1525 assert_eq!(
1526 nav.state().current_step,
1527 2,
1528 "Should still be at step 2 after dissolve"
1529 );
1530 assert!(!nav.state().hunk_preview_mode, "Should exit preview mode");
1531
1532 let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1534 let primary = view.iter().find(|l| l.is_primary_active);
1535 assert!(primary.is_some(), "Should have primary line");
1536 assert!(
1537 primary.unwrap().content.contains("LINE3"),
1538 "Primary should be on LINE3 after stepping"
1539 );
1540 }
1541
1542 #[test]
1543 fn test_next_hunk_completes_current_then_lands_on_next() {
1544 let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1547 let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nLINE7\nLINE8";
1548 let engine = DiffEngine::new();
1551 let diff = engine.diff_strings(old, new);
1552 assert!(diff.hunks.len() >= 2, "Must have 2 hunks");
1553
1554 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1555
1556 nav.next_hunk();
1558 assert_eq!(nav.state().current_hunk, 0);
1559 assert_eq!(
1560 nav.state().current_step,
1561 2,
1562 "Should apply all 2 changes in hunk 0"
1563 );
1564 assert!(nav.state().hunk_preview_mode);
1565
1566 nav.next_hunk();
1568 assert_eq!(nav.state().current_hunk, 1, "Should be in hunk 1");
1569
1570 assert_eq!(nav.state().current_step, 4, "Should have applied 4 changes");
1572 assert!(nav.state().hunk_preview_mode);
1573
1574 let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1576 let primary = view.iter().find(|l| l.is_primary_active);
1577 assert!(primary.is_some());
1578 assert!(
1579 primary.unwrap().content.contains("LINE7"),
1580 "Cursor should be on LINE7"
1581 );
1582 }
1583
1584 #[test]
1585 fn test_next_hunk_on_last_hunk_stays_at_end() {
1586 let old = "line1\nline2\nline3";
1589 let new = "line1\nLINE2\nLINE3";
1590
1591 let engine = DiffEngine::new();
1592 let diff = engine.diff_strings(old, new);
1593 assert_eq!(diff.hunks.len(), 1, "Must have exactly 1 hunk");
1594 assert_eq!(
1595 diff.hunks[0].change_ids.len(),
1596 2,
1597 "Hunk must have 2 changes"
1598 );
1599
1600 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1601
1602 let moved1 = nav.next_hunk();
1604 assert!(moved1);
1605 assert_eq!(nav.state().current_step, 2, "Should apply all 2 changes");
1606 assert!(nav.state().hunk_preview_mode);
1607
1608 let moved2 = nav.next_hunk();
1610 assert!(!moved2, "Should return false when no next hunk");
1611 assert_eq!(nav.state().current_step, 2, "Should still be at step 2");
1612 }
1613
1614 #[test]
1615 fn test_markers_persist_within_hunk() {
1616 let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1618 let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nline7\nline8";
1619
1620 let engine = DiffEngine::new();
1621 let diff = engine.diff_strings(old, new);
1622
1623 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1624
1625 nav.next_hunk();
1626 assert!(
1627 nav.state().last_nav_was_hunk,
1628 "last_nav_was_hunk should be true after next_hunk"
1629 );
1630
1631 nav.next();
1633 assert!(
1634 nav.state().last_nav_was_hunk,
1635 "last_nav_was_hunk should persist within hunk"
1636 );
1637 }
1638
1639 #[test]
1640 fn test_markers_clear_on_hunk_exit() {
1641 let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1643 let new = "line1\nLINE2\nline3\nline4\nline5\nline6\nLINE7\nline8";
1644 let engine = DiffEngine::new();
1647 let diff = engine.diff_strings(old, new);
1648 assert!(diff.hunks.len() >= 2, "Must have 2 hunks");
1649
1650 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1651
1652 nav.next_hunk();
1654 assert!(nav.state().last_nav_was_hunk);
1655
1656 nav.next();
1658 assert!(
1659 !nav.state().last_nav_was_hunk,
1660 "Markers should clear when stepping into different hunk"
1661 );
1662 }
1663
1664 #[test]
1665 fn test_active_change_flag_in_hunk_preview() {
1666 let old = "line1\nline2\nline3\n";
1669 let new = "LINE1\nLINE2\nline3\n";
1670
1671 let engine = DiffEngine::new();
1672 let diff = engine.diff_strings(old, new);
1673 assert_eq!(diff.hunks.len(), 1, "Expected a single hunk");
1674 assert!(
1675 diff.hunks[0].change_ids.len() >= 2,
1676 "Expected 2 changes in the hunk"
1677 );
1678
1679 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1680 nav.next_hunk();
1681
1682 let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1683 let active_changes: Vec<_> = view.iter().filter(|l| l.is_active_change).collect();
1684 let active_lines: Vec<_> = view.iter().filter(|l| l.is_active).collect();
1685
1686 assert_eq!(active_changes.len(), 1, "Only one active change expected");
1687 assert!(
1688 active_lines.len() >= 2,
1689 "All hunk lines should be active during preview"
1690 );
1691 assert!(
1692 active_changes[0].is_primary_active,
1693 "Active change should be primary"
1694 );
1695 assert!(
1696 active_changes[0].content.contains("LINE1"),
1697 "Active change should be the first change in the hunk"
1698 );
1699 }
1700
1701 #[test]
1702 fn test_word_level_phase_aware_mixed_change() {
1703 let old = "const foo = 4";
1706 let new = "const bar = 5";
1707
1708 let engine = DiffEngine::new().with_word_level(true);
1709 let diff = engine.diff_strings(old, new);
1710 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1711
1712 nav.next();
1714 assert!(nav.state().active_change.is_some());
1715
1716 let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1718 assert_eq!(view.len(), 1);
1719 assert_eq!(
1720 view[0].content, "const foo = 4",
1721 "FadeOut should show old content for mixed word-level change"
1722 );
1723
1724 let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1726 assert_eq!(view.len(), 1);
1727 assert_eq!(
1728 view[0].content, "const bar = 5",
1729 "FadeIn should show new content for mixed word-level change"
1730 );
1731
1732 let view = nav.current_view_with_frame(AnimationFrame::Idle);
1734 assert_eq!(view.len(), 1);
1735 assert_eq!(
1736 view[0].content, "const bar = 5",
1737 "Idle should show applied (new) content"
1738 );
1739 }
1740
1741 #[test]
1742 fn test_word_level_insert_only_visible_both_phases() {
1743 let old = "hello";
1745 let new = "hello world";
1746
1747 let engine = DiffEngine::new().with_word_level(true);
1748 let diff = engine.diff_strings(old, new);
1749 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1750
1751 nav.next();
1753
1754 let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1756 assert_eq!(view.len(), 1);
1757 assert!(
1758 view[0].content.contains("world"),
1759 "Insert-only change should be visible during FadeOut, got: {}",
1760 view[0].content
1761 );
1762
1763 let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1765 assert_eq!(view.len(), 1);
1766 assert!(
1767 view[0].content.contains("world"),
1768 "Insert-only change should be visible during FadeIn, got: {}",
1769 view[0].content
1770 );
1771 }
1772
1773 #[test]
1774 fn test_word_level_delete_only_visible_both_phases() {
1775 let old = "hello world";
1777 let new = "hello";
1778
1779 let engine = DiffEngine::new().with_word_level(true);
1780 let diff = engine.diff_strings(old, new);
1781 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1782
1783 nav.next();
1785
1786 let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1788 assert_eq!(view.len(), 1);
1789 assert!(
1790 view[0].content.contains("world"),
1791 "Delete-only change should show deleted content during FadeOut, got: {}",
1792 view[0].content
1793 );
1794
1795 let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1797 assert_eq!(view.len(), 1);
1798 assert!(
1799 view[0].content.contains("world"),
1800 "Delete-only change should show deleted content during FadeIn, got: {}",
1801 view[0].content
1802 );
1803 }
1804
1805 #[test]
1806 fn test_word_level_insert_only_idle_respects_applied_state() {
1807 let old = "hello";
1810 let new = "hello world";
1811
1812 let engine = DiffEngine::new().with_word_level(true);
1813 let diff = engine.diff_strings(old, new);
1814 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1815
1816 nav.next();
1818 nav.prev();
1819
1820 let view = nav.current_view_with_frame(AnimationFrame::Idle);
1822 assert_eq!(view.len(), 1);
1823 assert!(
1824 !view[0].content.contains("world"),
1825 "Idle should not show unapplied insert, got: {}",
1826 view[0].content
1827 );
1828 }
1829
1830 #[test]
1831 fn test_word_level_phase_aware_backward_with_multiple_changes() {
1832 let old = "aaa\nconst foo = 4\nccc\nddd\neee\nfff\nggg\nconst bar = 8\niii\njjj";
1835 let new = "aaa\nconst bbb = 5\nccc\nddd\neee\nfff\nggg\nconst qux = 9\niii\njjj";
1836 let engine = DiffEngine::new().with_word_level(true);
1839 let diff = engine.diff_strings(old, new);
1840
1841 assert!(
1843 diff.significant_changes.len() >= 2,
1844 "Expected at least 2 changes, got {}",
1845 diff.significant_changes.len()
1846 );
1847
1848 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1849
1850 nav.next(); nav.next(); assert_eq!(nav.state().current_step, 2);
1854
1855 nav.prev();
1857 assert_eq!(nav.state().current_step, 1);
1858 assert_eq!(nav.state().step_direction, StepDirection::Backward);
1859 assert!(nav.state().active_change.is_some());
1860
1861 let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1863 let active_line = view.iter().find(|l| l.is_active);
1864 assert!(active_line.is_some(), "Should have an active line");
1865
1866 let active = active_line.unwrap();
1869 assert_eq!(
1870 active.content, "const qux = 9",
1871 "Backward FadeOut should show new content (being removed)"
1872 );
1873
1874 let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1877 let active_line = view.iter().find(|l| l.is_active);
1878 assert!(active_line.is_some(), "Should have an active line");
1879 let active = active_line.unwrap();
1880 assert_eq!(
1881 active.content, "const bar = 8",
1882 "Backward FadeIn should show old content (being restored)"
1883 );
1884 }
1885
1886 #[test]
1887 fn test_word_level_insert_only_backward_visible_both_phases() {
1888 let old = "aaa\nhello\nccc\nddd\neee\nfff\nggg\nfoo\niii\njjj";
1892 let new = "aaa\nhello world\nccc\nddd\neee\nfff\nggg\nfoo bar\niii\njjj";
1893 let engine = DiffEngine::new().with_word_level(true);
1896 let diff = engine.diff_strings(old, new);
1897
1898 assert!(
1899 diff.significant_changes.len() >= 2,
1900 "Need 2+ changes to avoid landing on step 0, got {}",
1901 diff.significant_changes.len()
1902 );
1903
1904 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1905
1906 nav.next();
1908 nav.next();
1909 nav.prev();
1910
1911 assert_eq!(nav.state().step_direction, StepDirection::Backward);
1912 assert!(nav.state().active_change.is_some());
1913
1914 let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1916 let active_line = view.iter().find(|l| l.is_active);
1917 assert!(
1918 active_line.is_some(),
1919 "Should have an active line during FadeOut"
1920 );
1921 let active = active_line.unwrap();
1922 assert!(
1923 active.content.contains("bar"),
1924 "Backward FadeOut should show insert-only content, got: {}",
1925 active.content
1926 );
1927
1928 let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1930 let active_line = view.iter().find(|l| l.is_active);
1931 assert!(
1932 active_line.is_some(),
1933 "Should have an active line during FadeIn"
1934 );
1935 let active = active_line.unwrap();
1936 assert!(
1937 active.content.contains("bar"),
1938 "Backward FadeIn should show insert-only content, got: {}",
1939 active.content
1940 );
1941 }
1942
1943 #[test]
1944 fn test_word_level_active_line_kind_pending_modify() {
1945 let old = "const foo = 4";
1948 let new = "const bar = 5";
1949
1950 let engine = DiffEngine::new().with_word_level(true);
1951 let diff = engine.diff_strings(old, new);
1952 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1953
1954 nav.next(); let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1957 assert_eq!(view.len(), 1);
1958 assert_eq!(
1959 view[0].kind,
1960 LineKind::PendingModify,
1961 "Active word-level line should have PendingModify kind during FadeOut"
1962 );
1963
1964 let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1965 assert_eq!(view.len(), 1);
1966 assert_eq!(
1967 view[0].kind,
1968 LineKind::PendingModify,
1969 "Active word-level line should have PendingModify kind during FadeIn"
1970 );
1971 }
1972
1973 #[test]
1974 fn test_primary_active_unique_when_active_change_set() {
1975 let old = "a\nb\nc\n";
1978 let new = "a\nB\nc\n";
1979 let diff = DiffEngine::new().diff_strings(old, new);
1980 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1981
1982 nav.next();
1983 let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1984
1985 let primary: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
1986 assert_eq!(
1987 primary.len(),
1988 1,
1989 "Exactly one line should be primary active"
1990 );
1991 assert!(
1992 primary[0].is_active,
1993 "Primary active line must also be is_active"
1994 );
1995 }
1996
1997 #[test]
1998 fn test_hunk_extent_not_primary() {
1999 let old = "a\nb\nc\nd\n";
2002 let new = "A\nb\nC\nd\n"; let diff = DiffEngine::new().diff_strings(old, new);
2004
2005 assert_eq!(
2006 diff.hunks.len(),
2007 1,
2008 "Fixture should produce a single hunk; adjust the unchanged gap if this fails"
2009 );
2010
2011 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
2012
2013 nav.next_hunk();
2014 let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2015
2016 let active = view.iter().filter(|l| l.is_active).count();
2018 let extent = view.iter().filter(|l| l.show_hunk_extent).count();
2019 let primary = view.iter().filter(|l| l.is_primary_active).count();
2020
2021 assert!(
2022 active > 1,
2023 "Multiple lines should be active during animation, got {}",
2024 active
2025 );
2026 assert!(
2027 extent > 1,
2028 "Multiple lines should show extent markers, got {}",
2029 extent
2030 );
2031 assert_eq!(primary, 1, "Exactly one line should be primary active");
2032 }
2033
2034 #[test]
2035 fn test_hunk_extent_while_stepping() {
2036 let old = "one\ntwo\nthree\nfour\n";
2039 let new = "ONE\nTWO\nthree\nfour\n";
2040 let diff = DiffEngine::new().diff_strings(old, new);
2041 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
2042
2043 nav.next();
2044 nav.set_show_hunk_extent_while_stepping(true);
2045 let view = nav.current_view_with_frame(AnimationFrame::Idle);
2046
2047 let extent = view.iter().filter(|l| l.show_hunk_extent).count();
2048 assert!(
2049 extent > 0,
2050 "Extent markers should show while stepping when enabled"
2051 );
2052 }
2053
2054 #[test]
2055 fn test_primary_active_fallback_when_active_change_none() {
2056 let old = "a\nb\nc\n";
2059 let new = "A\nb\nC\n"; let diff = DiffEngine::new().diff_strings(old, new);
2061 let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
2062
2063 nav.state_mut().animating_hunk = Some(0);
2065 nav.state_mut().active_change = None;
2066 nav.state_mut().step_direction = StepDirection::Forward;
2067
2068 let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2069
2070 let primary: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
2071 assert_eq!(
2072 primary.len(),
2073 1,
2074 "Exactly one line should be primary active"
2075 );
2076 assert!(
2077 primary[0].is_active,
2078 "Primary active line must also be is_active"
2079 );
2080
2081 let first_active_idx = view.iter().position(|l| l.is_active).unwrap();
2083 let first_primary_idx = view.iter().position(|l| l.is_primary_active).unwrap();
2084 assert_eq!(
2085 first_active_idx, first_primary_idx,
2086 "First active line should be the primary line"
2087 );
2088 }
2089}