Skip to main content

oyo_core/
step.rs

1//! Step-through navigation for diffs
2
3use crate::change::{Change, ChangeKind, ChangeSpan};
4use crate::diff::DiffResult;
5use rustc_hash::FxHashSet;
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8
9/// Direction of the last step action
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
11pub enum StepDirection {
12    #[default]
13    None,
14    Forward,
15    Backward,
16}
17
18/// Animation frame for phase-aware rendering
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum AnimationFrame {
21    #[default]
22    Idle,
23    FadeOut,
24    FadeIn,
25}
26
27/// The current state of stepping through a diff
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct StepState {
30    /// Current step index (0 = initial state, 1 = after first change applied, etc.)
31    pub current_step: usize,
32    /// Total number of steps (number of significant changes + 1 for initial state)
33    pub total_steps: usize,
34    /// IDs of changes that have been applied up to current step
35    pub applied_changes: Vec<usize>,
36    /// Fast membership for applied changes (kept in sync with applied_changes)
37    #[serde(skip, default)]
38    applied_changes_set: FxHashSet<usize>,
39    /// ID of the change being highlighted/animated at current step
40    pub active_change: Option<usize>,
41    /// Cursor change used for non-stepping navigation (does not imply animation)
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub cursor_change: Option<usize>,
44    /// Hunk currently being animated (distinct from cursor position in current_hunk)
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub animating_hunk: Option<usize>,
47    /// Direction of the last step action
48    pub step_direction: StepDirection,
49    /// Current hunk index (0-based)
50    pub current_hunk: usize,
51    /// Total number of hunks
52    pub total_hunks: usize,
53    /// True if the last navigation was a hunk navigation (for extent marker display)
54    #[serde(default)]
55    pub last_nav_was_hunk: bool,
56    /// True after hunkdown (full preview mode), cleared on first step
57    #[serde(default)]
58    pub hunk_preview_mode: bool,
59    /// True if preview was entered via hunkup (backward navigation)
60    #[serde(default)]
61    pub preview_from_backward: bool,
62    /// Show hunk extent markers while stepping (set by UI)
63    #[serde(default)]
64    pub show_hunk_extent_while_stepping: bool,
65}
66
67impl StepState {
68    pub fn new(total_changes: usize, total_hunks: usize) -> Self {
69        Self {
70            current_step: 0,
71            total_steps: total_changes + 1, // +1 for initial state
72            applied_changes: Vec::new(),
73            applied_changes_set: FxHashSet::default(),
74            active_change: None,
75            cursor_change: None,
76            animating_hunk: None,
77            step_direction: StepDirection::None,
78            current_hunk: 0,
79            total_hunks,
80            last_nav_was_hunk: false,
81            hunk_preview_mode: false,
82            preview_from_backward: false,
83            show_hunk_extent_while_stepping: false,
84        }
85    }
86
87    /// Check if we're at the initial state (no changes applied)
88    pub fn is_at_start(&self) -> bool {
89        self.current_step == 0
90    }
91
92    /// Check if we're at the final state (all changes applied)
93    pub fn is_at_end(&self) -> bool {
94        self.current_step >= self.total_steps - 1
95    }
96
97    /// Get progress as a percentage
98    pub fn progress(&self) -> f64 {
99        if self.total_steps <= 1 {
100            return 100.0;
101        }
102        (self.current_step as f64 / (self.total_steps - 1) as f64) * 100.0
103    }
104
105    fn rebuild_applied_set(&mut self) {
106        self.applied_changes_set = self.applied_changes.iter().copied().collect();
107    }
108
109    pub fn is_applied(&self, change_id: usize) -> bool {
110        self.applied_changes_set.contains(&change_id)
111    }
112
113    fn push_applied(&mut self, change_id: usize) {
114        if self.applied_changes_set.insert(change_id) {
115            self.applied_changes.push(change_id);
116        }
117    }
118
119    fn pop_applied(&mut self) -> Option<usize> {
120        let change_id = self.applied_changes.pop()?;
121        self.applied_changes_set.remove(&change_id);
122        Some(change_id)
123    }
124
125    fn truncate_applied_to(&mut self, new_len: usize) -> usize {
126        let old_len = self.applied_changes.len();
127        if new_len >= old_len {
128            return 0;
129        }
130        for change_id in &self.applied_changes[new_len..] {
131            self.applied_changes_set.remove(change_id);
132        }
133        self.applied_changes.truncate(new_len);
134        old_len - new_len
135    }
136
137    fn clear_applied(&mut self) {
138        self.applied_changes.clear();
139        self.applied_changes_set.clear();
140    }
141}
142
143/// Navigator for stepping through diff changes
144pub struct DiffNavigator {
145    /// The diff result we're navigating
146    diff: DiffResult,
147    /// Current step state
148    state: StepState,
149    /// Original content (for reconstructing views)
150    old_content: Arc<str>,
151    /// New content (for reconstructing views)
152    new_content: Arc<str>,
153    /// Mapping from change ID to hunk index
154    change_to_hunk: Vec<Option<usize>>,
155    /// Exact mapping from change ID to hunk index (no context padding)
156    change_id_to_hunk_exact: Vec<Option<usize>>,
157    /// Mapping from change ID to change index in the diff
158    change_to_index: Vec<Option<usize>>,
159    /// Mapping from change ID to step index (significant_changes order)
160    change_to_step_index: Vec<Option<usize>>,
161    /// Skip building full lookup maps for large diffs
162    lazy_maps: bool,
163    /// Step range (start index, length) per hunk for fast hunk progress
164    hunk_step_ranges: Vec<Option<HunkStepRange>>,
165    /// Cached change index range per hunk (inclusive), for O(1) scope checks
166    hunk_change_ranges: Vec<Option<(usize, usize)>>,
167    /// Exact change index range per hunk (inclusive), no context padding
168    hunk_change_ranges_exact: Vec<Option<(usize, usize)>>,
169    /// Cached display indices for evolution view (None for hidden deletions)
170    evo_visible_index: Option<Vec<Option<usize>>>,
171    /// Cached visible line count for evolution view
172    evo_visible_len: Option<usize>,
173    /// Cached list of visible change indices (display index -> change index)
174    evo_display_to_change: Option<Vec<usize>>,
175    /// Cached nearest visible change index per change (for evo)
176    evo_nearest_visible: Option<Vec<Option<usize>>>,
177}
178
179const LARGE_CONTEXT_PAD: usize = 3;
180
181#[derive(Debug, Clone, Copy)]
182struct HunkStepRange {
183    start: usize,
184    len: usize,
185}
186
187impl DiffNavigator {
188    pub fn new(
189        diff: DiffResult,
190        old_content: Arc<str>,
191        new_content: Arc<str>,
192        lazy_maps: bool,
193    ) -> Self {
194        let total_changes = diff.significant_changes.len();
195        let total_hunks = diff.hunks.len();
196
197        // Build change ID lookup maps
198        let mut change_to_hunk = vec![None; diff.changes.len()];
199        let mut max_change_id = 0usize;
200        for change in diff.changes.iter() {
201            max_change_id = max_change_id.max(change.id);
202        }
203        let mut change_to_index = vec![None; max_change_id.saturating_add(1)];
204        for (idx, change) in diff.changes.iter().enumerate() {
205            if let Some(slot) = change_to_index.get_mut(change.id) {
206                *slot = Some(idx);
207            }
208        }
209        let mut change_to_step_index = vec![None; max_change_id.saturating_add(1)];
210        for (idx, change_id) in diff.significant_changes.iter().enumerate() {
211            if let Some(slot) = change_to_step_index.get_mut(*change_id) {
212                *slot = Some(idx);
213            }
214        }
215
216        let mut change_id_to_hunk_exact = vec![None; max_change_id.saturating_add(1)];
217        let mut hunk_change_ranges = vec![None; diff.hunks.len()];
218        let mut hunk_change_ranges_exact = vec![None; diff.hunks.len()];
219        for (hunk_idx, hunk) in diff.hunks.iter().enumerate() {
220            let mut min_idx = usize::MAX;
221            let mut max_idx = 0usize;
222            for &change_id in &hunk.change_ids {
223                if let Some(slot) = change_id_to_hunk_exact.get_mut(change_id) {
224                    *slot = Some(hunk.id);
225                }
226                if let Some(Some(idx)) = change_to_index.get(change_id) {
227                    min_idx = min_idx.min(*idx);
228                    max_idx = max_idx.max(*idx);
229                }
230            }
231            if min_idx == usize::MAX {
232                continue;
233            }
234            hunk_change_ranges_exact[hunk_idx] = Some((min_idx, max_idx));
235            let start = if lazy_maps {
236                min_idx.saturating_sub(LARGE_CONTEXT_PAD)
237            } else {
238                min_idx
239            };
240            let end = if lazy_maps {
241                (max_idx + LARGE_CONTEXT_PAD).min(diff.changes.len().saturating_sub(1))
242            } else {
243                max_idx
244            };
245            hunk_change_ranges[hunk_idx] = Some((start, end));
246            for idx in start..=end {
247                if let Some(slot) = change_to_hunk.get_mut(idx) {
248                    if slot.is_none() {
249                        *slot = Some(hunk_idx);
250                    }
251                }
252            }
253        }
254
255        let mut hunk_step_ranges = vec![None; diff.hunks.len()];
256        for (hunk_idx, hunk) in diff.hunks.iter().enumerate() {
257            if hunk.change_ids.is_empty() {
258                continue;
259            }
260            let mut min = usize::MAX;
261            let mut count = 0usize;
262            for change_id in &hunk.change_ids {
263                if let Some(Some(step_idx)) = change_to_step_index.get(*change_id) {
264                    if *step_idx < min {
265                        min = *step_idx;
266                    }
267                    count += 1;
268                }
269            }
270            if count > 0 && min != usize::MAX {
271                hunk_step_ranges[hunk_idx] = Some(HunkStepRange {
272                    start: min,
273                    len: count,
274                });
275            }
276        }
277
278        Self {
279            diff,
280            state: StepState::new(total_changes, total_hunks),
281            old_content,
282            new_content,
283            change_to_hunk,
284            change_id_to_hunk_exact,
285            change_to_index,
286            change_to_step_index,
287            lazy_maps,
288            hunk_step_ranges,
289            hunk_change_ranges,
290            hunk_change_ranges_exact,
291            evo_visible_index: None,
292            evo_visible_len: None,
293            evo_display_to_change: None,
294            evo_nearest_visible: None,
295        }
296    }
297
298    /// Get the current step state
299    pub fn state(&self) -> &StepState {
300        &self.state
301    }
302
303    /// Get mutable access to step state (test-only)
304    #[cfg(test)]
305    pub fn state_mut(&mut self) -> &mut StepState {
306        &mut self.state
307    }
308
309    /// Replace the current step state (used to restore stepping mode)
310    pub fn set_state(&mut self, state: StepState) -> bool {
311        if state.total_steps != self.state.total_steps
312            || state.total_hunks != self.state.total_hunks
313        {
314            return false;
315        }
316        self.state = state;
317        self.state.rebuild_applied_set();
318        true
319    }
320
321    /// Get the diff result
322    pub fn diff(&self) -> &DiffResult {
323        &self.diff
324    }
325
326    fn change_visible_in_evolution(change: &Change) -> bool {
327        let mut has_old = false;
328        let mut has_new = false;
329        for span in &change.spans {
330            match span.kind {
331                ChangeKind::Insert => has_new = true,
332                ChangeKind::Delete => has_old = true,
333                ChangeKind::Replace => {
334                    has_old = true;
335                    has_new = true;
336                }
337                ChangeKind::Equal => {}
338            }
339        }
340        !has_old || has_new
341    }
342
343    fn change_visible_in_evolution_state(&self, change: &Change) -> bool {
344        let applied = self.state.is_applied(change.id);
345        let mut has_old = false;
346        let mut has_new = false;
347        for span in &change.spans {
348            match span.kind {
349                ChangeKind::Insert => has_new = true,
350                ChangeKind::Delete => has_old = true,
351                ChangeKind::Replace => {
352                    has_old = true;
353                    has_new = true;
354                }
355                ChangeKind::Equal => {}
356            }
357        }
358        if has_old && !has_new {
359            return !applied;
360        }
361        if has_new && !has_old {
362            return applied;
363        }
364        true
365    }
366
367    fn ensure_evo_visible_index(&mut self) {
368        if self.evo_visible_index.is_some() {
369            return;
370        }
371        let mut mapping = Vec::with_capacity(self.diff.changes.len());
372        let mut display_to_change = Vec::new();
373        let mut display_idx = 0usize;
374        for (idx, change) in self.diff.changes.iter().enumerate() {
375            if Self::change_visible_in_evolution(change) {
376                mapping.push(Some(display_idx));
377                display_to_change.push(idx);
378                display_idx += 1;
379            } else {
380                mapping.push(None);
381            }
382        }
383        self.evo_visible_len = Some(display_idx);
384        self.evo_visible_index = Some(mapping);
385        self.evo_display_to_change = Some(display_to_change);
386
387        let mut prev_visible = vec![None; self.diff.changes.len()];
388        let mut last_visible = None;
389        for (idx, change) in self.diff.changes.iter().enumerate() {
390            if Self::change_visible_in_evolution(change) {
391                last_visible = Some(idx);
392            }
393            prev_visible[idx] = last_visible;
394        }
395        let mut next_visible = vec![None; self.diff.changes.len()];
396        let mut next = None;
397        for (idx, change) in self.diff.changes.iter().enumerate().rev() {
398            if Self::change_visible_in_evolution(change) {
399                next = Some(idx);
400            }
401            next_visible[idx] = next;
402        }
403        let mut nearest = vec![None; self.diff.changes.len()];
404        for idx in 0..self.diff.changes.len() {
405            match (prev_visible[idx], next_visible[idx]) {
406                (Some(prev), Some(next)) => {
407                    let prev_dist = idx.saturating_sub(prev);
408                    let next_dist = next.saturating_sub(idx);
409                    nearest[idx] = if next_dist < prev_dist {
410                        Some(next)
411                    } else {
412                        Some(prev)
413                    };
414                }
415                (Some(prev), None) => nearest[idx] = Some(prev),
416                (None, Some(next)) => nearest[idx] = Some(next),
417                (None, None) => nearest[idx] = None,
418            }
419        }
420        self.evo_nearest_visible = Some(nearest);
421    }
422
423    pub fn evolution_display_index_for_change(&mut self, change_id: usize) -> Option<usize> {
424        self.ensure_evo_visible_index();
425        let idx = self.change_index_for(change_id)?;
426        self.evo_visible_index
427            .as_ref()
428            .and_then(|mapping| mapping.get(idx).copied().flatten())
429    }
430
431    pub fn evolution_display_index_for_change_index(&mut self, change_idx: usize) -> Option<usize> {
432        self.ensure_evo_visible_index();
433        self.evo_visible_index
434            .as_ref()
435            .and_then(|mapping| mapping.get(change_idx).copied().flatten())
436    }
437
438    pub fn evolution_display_index_or_nearest(&mut self, change_id: usize) -> Option<usize> {
439        if let Some(idx) = self.evolution_display_index_for_change(change_id) {
440            return Some(idx);
441        }
442        self.ensure_evo_visible_index();
443        let change_idx = self.change_index_for(change_id)?;
444        let nearest = self
445            .evo_nearest_visible
446            .as_ref()
447            .and_then(|mapping| mapping.get(change_idx).copied().flatten())?;
448        self.evolution_display_index_for_change_index(nearest)
449    }
450
451    pub fn evolution_nearest_visible_change_id(&mut self, change_id: usize) -> Option<usize> {
452        self.ensure_evo_visible_index();
453        let change_idx = self.change_index_for(change_id)?;
454        let nearest = self
455            .evo_nearest_visible
456            .as_ref()
457            .and_then(|mapping| mapping.get(change_idx).copied().flatten())?;
458        self.diff.changes.get(nearest).map(|change| change.id)
459    }
460
461    pub fn evolution_nearest_visible_change_id_dynamic(
462        &self,
463        change_id: usize,
464        max_scan: usize,
465    ) -> Option<usize> {
466        let idx = self.change_index_for(change_id)?;
467        if self.diff.changes.is_empty() {
468            return None;
469        }
470        let mut offset = 0usize;
471        while offset <= max_scan {
472            if let Some(left) = idx.checked_sub(offset) {
473                let change = &self.diff.changes[left];
474                if self.change_visible_in_evolution_state(change) {
475                    return Some(change.id);
476                }
477            }
478            let right = idx + offset;
479            if right < self.diff.changes.len() {
480                let change = &self.diff.changes[right];
481                if self.change_visible_in_evolution_state(change) {
482                    return Some(change.id);
483                }
484            }
485            offset += 1;
486        }
487        None
488    }
489
490    pub fn evolution_visible_len(&mut self) -> usize {
491        self.ensure_evo_visible_index();
492        self.evo_visible_len.unwrap_or(0)
493    }
494
495    pub fn evolution_change_range_for_display(
496        &mut self,
497        display_idx: usize,
498        radius: usize,
499    ) -> Option<(usize, usize)> {
500        self.ensure_evo_visible_index();
501        let visible_len = self.evo_visible_len?;
502        if visible_len == 0 {
503            return None;
504        }
505        let display_idx = display_idx.min(visible_len.saturating_sub(1));
506        let start_display = display_idx.saturating_sub(radius);
507        let end_display = (display_idx + radius).min(visible_len.saturating_sub(1));
508        let display_to_change = self.evo_display_to_change.as_ref()?;
509        let start_change = *display_to_change.get(start_display)?;
510        let end_change = *display_to_change.get(end_display)?;
511        Some((start_change, end_change))
512    }
513
514    /// Set a non-animated cursor for classic (no-step) navigation.
515    pub fn set_cursor_hunk(&mut self, hunk_idx: usize, change_id: Option<usize>) {
516        if self.state.total_hunks > 0 {
517            self.state.current_hunk = hunk_idx.min(self.state.total_hunks - 1);
518        }
519        self.state.cursor_change = change_id;
520    }
521
522    /// Override the active cursor without changing applied steps.
523    pub fn set_cursor_override(&mut self, change_id: Option<usize>) {
524        self.state.cursor_change = change_id;
525        self.state.active_change = None;
526        self.state.step_direction = StepDirection::None;
527        self.state.animating_hunk = None;
528    }
529
530    /// Set cursor change without altering current hunk.
531    pub fn set_cursor_change(&mut self, change_id: Option<usize>) {
532        self.state.cursor_change = change_id;
533    }
534
535    /// Clear the non-animated cursor.
536    pub fn clear_cursor_change(&mut self) {
537        self.state.cursor_change = None;
538    }
539
540    /// Control hunk scope markers (extent indicators).
541    pub fn set_hunk_scope(&mut self, enabled: bool) {
542        self.state.last_nav_was_hunk = enabled;
543    }
544
545    /// Move to the next step
546    #[allow(clippy::should_implement_trait)]
547    pub fn next(&mut self) -> bool {
548        // Handle preview mode dissolution on first step
549        if self.state.hunk_preview_mode {
550            return self.dissolve_preview_for_step_down();
551        }
552
553        if self.state.is_at_end() {
554            return false;
555        }
556
557        let prev_hunk = self.state.current_hunk;
558
559        self.state.step_direction = StepDirection::Forward;
560        self.state.animating_hunk = None; // Clear hunk animation for single-step
561
562        // Get the next change to apply
563        let change_idx = self.state.current_step;
564        if change_idx < self.diff.significant_changes.len() {
565            let change_id = self.diff.significant_changes[change_idx];
566            self.state.push_applied(change_id);
567            self.state.active_change = Some(change_id);
568
569            // Update current hunk
570            if let Some(hunk) = self.diff.hunk_for_change(change_id) {
571                self.state.current_hunk = hunk.id;
572            }
573        }
574
575        self.state.current_step += 1;
576
577        // Clear extent markers only when leaving hunk
578        if self.state.current_hunk != prev_hunk {
579            self.state.last_nav_was_hunk = false;
580        }
581
582        true
583    }
584
585    /// Dissolve preview mode on step down: keep first change, apply second
586    fn dissolve_preview_for_step_down(&mut self) -> bool {
587        if self.state.preview_from_backward {
588            self.state.hunk_preview_mode = false;
589            self.state.preview_from_backward = false;
590            return self.next();
591        }
592
593        let current_hunk_idx = self.state.current_hunk;
594        let hunk_len = self
595            .diff
596            .hunks
597            .get(current_hunk_idx)
598            .map(|hunk| hunk.change_ids.len())
599            .unwrap_or(0);
600
601        // If hunk has only one change, stepping down exits the hunk
602        if hunk_len <= 1 {
603            self.state.hunk_preview_mode = false;
604            // Let normal next() handle moving to next change/hunk
605            return self.next();
606        }
607
608        // Keep only first change, unapply the rest
609        let (first_change, second_change) = {
610            let hunk = &self.diff.hunks[current_hunk_idx];
611            (hunk.change_ids[0], hunk.change_ids[1])
612        };
613
614        // Remove all changes in this hunk except the first
615        self.remove_applied_bulk_for_hunk(current_hunk_idx, Some(first_change));
616
617        // Apply second change
618        self.state.push_applied(second_change);
619
620        // Update current_step to reflect actual applied changes
621        self.state.current_step = self.state.applied_changes.len();
622
623        self.state.active_change = Some(second_change);
624        self.state.step_direction = StepDirection::Forward;
625        self.state.hunk_preview_mode = false;
626        self.state.preview_from_backward = false;
627        self.state.animating_hunk = None;
628        // Preserve extent markers - we're still in the hunk scope
629        self.state.last_nav_was_hunk = true;
630
631        true
632    }
633
634    /// Move to the previous step
635    pub fn prev(&mut self) -> bool {
636        // Handle preview mode dissolution on first step up: exit hunk entirely
637        if self.state.hunk_preview_mode {
638            return self.dissolve_preview_for_step_up();
639        }
640
641        if self.state.is_at_start() {
642            return false;
643        }
644
645        let prev_hunk = self.state.current_hunk;
646
647        self.state.step_direction = StepDirection::Backward;
648        self.state.animating_hunk = None; // Clear hunk animation for single-step
649        self.state.current_step -= 1;
650
651        // Pop the change and set it as active for backward animation
652        if let Some(unapplied_change_id) = self.state.pop_applied() {
653            self.state.active_change = Some(unapplied_change_id);
654
655            // Update current hunk based on last applied change
656            if let Some(&last_applied) = self.state.applied_changes.last() {
657                if let Some(hunk) = self.diff.hunk_for_change(last_applied) {
658                    self.state.current_hunk = hunk.id;
659                }
660            } else {
661                self.state.current_hunk = 0;
662            }
663        } else {
664            self.state.active_change = None;
665        }
666
667        // Reset hunk cursor when at start; animation state cleared by CLI after animation completes
668        if self.state.is_at_start() {
669            self.state.current_hunk = 0;
670        }
671
672        // Clear extent markers when leaving hunk or at step 0
673        if self.state.is_at_start() || self.state.current_hunk != prev_hunk {
674            self.state.last_nav_was_hunk = false;
675        }
676
677        true
678    }
679
680    /// Dissolve preview mode on step up: unapply all changes in hunk and exit
681    fn dissolve_preview_for_step_up(&mut self) -> bool {
682        if self.state.preview_from_backward {
683            self.state.hunk_preview_mode = false;
684            self.state.preview_from_backward = false;
685            return self.prev();
686        }
687
688        let current_hunk_idx = self.state.current_hunk;
689
690        // Set animating hunk for backward fade animation
691        self.state.animating_hunk = Some(current_hunk_idx);
692
693        // Unapply all changes in this hunk
694        self.remove_applied_bulk_for_hunk(current_hunk_idx, None);
695
696        // Update current_step to reflect actual applied changes
697        self.state.current_step = self.state.applied_changes.len();
698
699        // Move to previous hunk if possible
700        if current_hunk_idx > 0 {
701            self.state.current_hunk = current_hunk_idx - 1;
702        }
703
704        self.state.step_direction = StepDirection::Backward;
705        // Keep cursor logic aligned with the destination hunk (bottom-most applied change)
706        self.state.active_change = self.state.applied_changes.last().copied();
707        self.state.hunk_preview_mode = false;
708        self.state.preview_from_backward = false;
709        self.state.last_nav_was_hunk = false; // Exiting hunk
710
711        true
712    }
713
714    /// Clear animation state (called after animation completes or one-frame render)
715    /// For backward steps, keeps cursor on last applied change (destination)
716    pub fn clear_active_change(&mut self) {
717        if self.state.step_direction == StepDirection::Backward {
718            self.state.active_change = self.state.applied_changes.last().copied();
719        } else {
720            self.state.active_change = None;
721        }
722        self.state.animating_hunk = None;
723        self.state.step_direction = StepDirection::None;
724    }
725
726    fn remove_applied_bulk_for_hunk(&mut self, hunk_idx: usize, keep: Option<usize>) -> usize {
727        let (diff, change_to_step_index, state) =
728            (&self.diff, &self.change_to_step_index, &mut self.state);
729        let Some(hunk) = diff.hunks.get(hunk_idx) else {
730            return 0;
731        };
732
733        let mut min_index: Option<usize> = None;
734        let mut keep_index: Option<usize> = None;
735
736        for &change_id in &hunk.change_ids {
737            if Some(change_id) == keep {
738                keep_index = change_to_step_index.get(change_id).copied().flatten();
739                continue;
740            }
741            if !state.is_applied(change_id) {
742                continue;
743            }
744            if let Some(step_idx) = change_to_step_index.get(change_id).copied().flatten() {
745                min_index = Some(min_index.map_or(step_idx, |min| min.min(step_idx)));
746            }
747        }
748
749        let new_len = if let Some(keep_id) = keep {
750            let Some(step_idx) = keep_index else {
751                return 0;
752            };
753            if !state.is_applied(keep_id) {
754                return 0;
755            }
756            step_idx + 1
757        } else {
758            let Some(step_idx) = min_index else {
759                return 0;
760            };
761            step_idx
762        };
763
764        state.truncate_applied_to(new_len)
765    }
766
767    /// Jump to a specific step
768    pub fn goto(&mut self, step: usize) {
769        let target_step = step.min(self.state.total_steps - 1);
770
771        // Reset to start
772        self.state.current_step = 0;
773        self.state.clear_applied();
774        self.state.active_change = None;
775        self.state.cursor_change = None;
776        self.state.animating_hunk = None;
777        self.state.current_hunk = 0;
778        self.state.last_nav_was_hunk = false; // Clear hunk nav flag on goto
779        self.state.hunk_preview_mode = false; // Clear preview mode on goto
780        self.state.preview_from_backward = false;
781
782        if self.lazy_maps {
783            if target_step > 0 {
784                let end = target_step.min(self.diff.significant_changes.len());
785                self.state.applied_changes = self.diff.significant_changes[..end].to_vec();
786                self.state.rebuild_applied_set();
787                self.state.current_step = end;
788                self.state.active_change = self.state.applied_changes.last().copied();
789                self.state.step_direction = StepDirection::Forward;
790            } else {
791                self.state.step_direction = StepDirection::None;
792            }
793        } else {
794            // Apply changes up to target step
795            for _ in 0..target_step {
796                self.next();
797            }
798        }
799
800        // Update which hunk we're in
801        self.update_current_hunk();
802    }
803
804    /// Go to the start
805    pub fn goto_start(&mut self) {
806        self.goto(0);
807    }
808
809    /// Go to the end
810    pub fn goto_end(&mut self) {
811        self.goto(self.state.total_steps - 1);
812    }
813
814    // ==================== Hunk Navigation ====================
815
816    /// Move to the next hunk, applying ALL changes (full preview mode).
817    /// If current hunk is not started, applies all its changes with cursor at top.
818    /// If current hunk is partially/fully applied, completes it and moves to next hunk.
819    /// Returns true if moved, false if no movement possible
820    pub fn next_hunk(&mut self) -> bool {
821        if self.diff.hunks.is_empty() {
822            return false;
823        }
824
825        // Preserve preview mode in case we return false without doing anything
826        let was_in_preview = self.state.hunk_preview_mode;
827
828        self.state.step_direction = StepDirection::Forward;
829        self.state.hunk_preview_mode = false; // Will be set to true after applying
830        self.state.preview_from_backward = false;
831
832        let current_hunk = &self.diff.hunks[self.state.current_hunk];
833        let has_applied_in_current = current_hunk
834            .change_ids
835            .iter()
836            .any(|id| self.state.is_applied(*id));
837
838        // If current hunk has no applied changes, apply ALL changes (full preview)
839        if !has_applied_in_current {
840            let mut moved = false;
841            for &change_id in &current_hunk.change_ids {
842                if !self.state.is_applied(change_id) {
843                    self.state.push_applied(change_id);
844                    self.state.current_step += 1;
845                    moved = true;
846                }
847            }
848
849            self.state.animating_hunk = Some(self.state.current_hunk);
850            self.state.active_change = current_hunk.change_ids.first().copied();
851
852            if moved {
853                self.state.last_nav_was_hunk = true;
854                self.state.hunk_preview_mode = true;
855                self.state.preview_from_backward = false;
856            }
857
858            return moved;
859        }
860
861        // Current hunk has applied changes - complete it first, exit preview mode
862        self.state.hunk_preview_mode = false;
863        self.state.preview_from_backward = false;
864        let mut completed_any = false;
865        for &change_id in &current_hunk.change_ids {
866            if !self.state.is_applied(change_id) {
867                self.state.push_applied(change_id);
868                self.state.current_step += 1;
869                completed_any = true;
870            }
871        }
872
873        // Move to next hunk
874        let next_hunk_idx = self.state.current_hunk + 1;
875        if next_hunk_idx >= self.diff.hunks.len() {
876            // No next hunk - if we completed current hunk, update state; otherwise return false
877            if completed_any {
878                self.state.animating_hunk = Some(self.state.current_hunk);
879                self.state.active_change = current_hunk.change_ids.last().copied();
880                self.state.last_nav_was_hunk = true;
881                return true;
882            }
883            // No movement, restore preview mode
884            self.state.hunk_preview_mode = was_in_preview;
885            return false;
886        }
887
888        let hunk = &self.diff.hunks[next_hunk_idx];
889
890        // Apply ALL changes of next hunk (full preview)
891        let mut moved = false;
892        for &change_id in &hunk.change_ids {
893            if !self.state.is_applied(change_id) {
894                self.state.push_applied(change_id);
895                self.state.current_step += 1;
896                moved = true;
897            }
898        }
899
900        self.state.animating_hunk = Some(next_hunk_idx);
901        self.state.active_change = hunk.change_ids.first().copied();
902        self.state.current_hunk = next_hunk_idx;
903
904        if moved {
905            self.state.last_nav_was_hunk = true;
906            self.state.hunk_preview_mode = true;
907            self.state.preview_from_backward = false;
908        }
909
910        moved
911    }
912
913    /// Move to the previous hunk, unapplying changes
914    /// Returns true if moved, false if nothing to unapply
915    pub fn prev_hunk(&mut self) -> bool {
916        if self.diff.hunks.is_empty() {
917            return false;
918        }
919
920        // Preserve preview mode in case we return false without doing anything
921        let was_in_preview = self.state.hunk_preview_mode;
922
923        // Clear preview mode
924        self.state.hunk_preview_mode = false;
925        self.state.preview_from_backward = false;
926
927        // On hunk 0, only proceed if there are applied changes to unapply
928        if self.state.current_hunk == 0 {
929            let hunk = &self.diff.hunks[0];
930            let has_applied = hunk.change_ids.iter().any(|id| self.state.is_applied(*id));
931            if !has_applied {
932                // No movement, restore preview mode
933                self.state.hunk_preview_mode = was_in_preview;
934                return false;
935            }
936        }
937
938        self.state.step_direction = StepDirection::Backward;
939
940        // If we have applied changes in current hunk, unapply them
941        let current_hunk_idx = self.state.current_hunk;
942        let mut moved = false;
943
944        // Unapply changes from current hunk that are applied
945        let removed = self.remove_applied_bulk_for_hunk(current_hunk_idx, None);
946        if removed > 0 {
947            self.state.current_step = self.state.applied_changes.len();
948            moved = true;
949        }
950
951        // Set animating hunk for whole-hunk animation (keep pointing at the hunk
952        // being removed so is_change_in_animating_hunk returns true during fade)
953        self.state.animating_hunk = Some(current_hunk_idx);
954        self.state.active_change = self
955            .diff
956            .hunks
957            .get(current_hunk_idx)
958            .and_then(|hunk| hunk.change_ids.first().copied());
959
960        // Move to previous hunk if current is now empty of applied changes
961        // (current_hunk tracks cursor position, animating_hunk tracks animation)
962        if moved {
963            // Check if we should move to previous hunk
964            let still_has_applied = self
965                .diff
966                .hunks
967                .get(current_hunk_idx)
968                .map(|hunk| hunk.change_ids.iter().any(|id| self.state.is_applied(*id)))
969                .unwrap_or(false);
970            if !still_has_applied && self.state.current_hunk > 0 {
971                self.state.current_hunk -= 1;
972            }
973        } else if self.state.current_hunk > 0 {
974            // Nothing in current hunk was applied, try previous
975            self.state.current_hunk -= 1;
976            return self.prev_hunk();
977        }
978
979        // Don't overwrite animating_hunk here - let animation complete first.
980        // current_hunk already tracks cursor position for status display.
981
982        // Animation state cleared by CLI after animation completes
983
984        // Enter preview mode when we land in a previous hunk
985        if moved {
986            let entered_prev_hunk = self.state.current_hunk != current_hunk_idx;
987            if entered_prev_hunk {
988                self.state.hunk_preview_mode = true;
989                self.state.preview_from_backward = true;
990                self.state.last_nav_was_hunk = true;
991            } else {
992                // Set or clear extent markers based on whether we landed at step 0
993                self.state.last_nav_was_hunk = !self.state.is_at_start();
994            }
995        }
996
997        moved
998    }
999
1000    /// Go to a specific hunk (0-indexed)
1001    /// Applies all changes through target hunk (full preview mode).
1002    /// Cursor lands at top of target hunk.
1003    pub fn goto_hunk(&mut self, hunk_idx: usize) {
1004        if hunk_idx >= self.diff.hunks.len() {
1005            return;
1006        }
1007
1008        // Reset to start
1009        self.goto_start();
1010
1011        // Apply all changes for hunks before target
1012        for idx in 0..hunk_idx {
1013            let hunk = &self.diff.hunks[idx];
1014            for &change_id in &hunk.change_ids {
1015                self.state.push_applied(change_id);
1016                self.state.current_step += 1;
1017            }
1018        }
1019
1020        // Apply ALL changes of target hunk (full preview)
1021        let hunk = &self.diff.hunks[hunk_idx];
1022        for &change_id in &hunk.change_ids {
1023            self.state.push_applied(change_id);
1024            self.state.current_step += 1;
1025        }
1026
1027        self.state.current_hunk = hunk_idx;
1028        self.state.animating_hunk = Some(hunk_idx);
1029        self.state.active_change = hunk.change_ids.first().copied();
1030        self.state.step_direction = StepDirection::Forward;
1031        self.state.last_nav_was_hunk = true;
1032        self.state.hunk_preview_mode = true;
1033        self.state.preview_from_backward = false;
1034    }
1035
1036    /// Jump to first change of current hunk, unapplying all but first
1037    /// Returns true if moved, false if not inside a hunk or already at start
1038    pub fn goto_hunk_start(&mut self) -> bool {
1039        if self.diff.hunks.is_empty() {
1040            return false;
1041        }
1042
1043        let current_hunk_idx = self.state.current_hunk;
1044        let first_change = match self
1045            .diff
1046            .hunks
1047            .get(current_hunk_idx)
1048            .and_then(|hunk| hunk.change_ids.first().copied())
1049        {
1050            Some(id) => id,
1051            None => return false,
1052        };
1053
1054        // Must have at least first change applied to be "inside" hunk
1055        if !self.state.is_applied(first_change) {
1056            return false;
1057        }
1058
1059        // Unapply all changes in this hunk except the first
1060        let removed = self.remove_applied_bulk_for_hunk(current_hunk_idx, Some(first_change));
1061        let unapplied_any = removed > 0;
1062        if removed > 0 {
1063            self.state.current_step = self.state.applied_changes.len();
1064        }
1065
1066        // No-op if already at start (nothing unapplied and cursor on first)
1067        if !unapplied_any && self.state.active_change == Some(first_change) {
1068            return false;
1069        }
1070
1071        self.state.active_change = Some(first_change);
1072        self.state.hunk_preview_mode = false;
1073        self.state.preview_from_backward = false;
1074        self.state.last_nav_was_hunk = true;
1075        true
1076    }
1077
1078    /// Jump to last change of current hunk, applying all changes in hunk
1079    /// Returns true if moved, false if not inside a hunk or already at end
1080    pub fn goto_hunk_end(&mut self) -> bool {
1081        if self.diff.hunks.is_empty() {
1082            return false;
1083        }
1084
1085        let hunk = &self.diff.hunks[self.state.current_hunk];
1086        let has_applied = hunk.change_ids.iter().any(|id| self.state.is_applied(*id));
1087        if !has_applied {
1088            return false;
1089        }
1090
1091        let last_change = hunk.change_ids.last().copied();
1092
1093        // Apply all unapplied changes in this hunk
1094        for &change_id in &hunk.change_ids {
1095            if !self.state.is_applied(change_id) {
1096                self.state.push_applied(change_id);
1097                self.state.current_step += 1;
1098            }
1099        }
1100
1101        // No-op if already at end (cursor on last)
1102        if self.state.active_change == last_change {
1103            return false;
1104        }
1105
1106        self.state.active_change = last_change;
1107        self.state.hunk_preview_mode = false;
1108        self.state.preview_from_backward = false;
1109        self.state.last_nav_was_hunk = true;
1110        true
1111    }
1112
1113    /// Update current hunk based on applied changes
1114    pub fn update_current_hunk(&mut self) {
1115        if self.diff.hunks.is_empty() {
1116            return;
1117        }
1118
1119        // Find which hunk contains the most recently applied change
1120        if let Some(&last_applied) = self.state.applied_changes.last() {
1121            for (idx, hunk) in self.diff.hunks.iter().enumerate() {
1122                if hunk.change_ids.contains(&last_applied) {
1123                    self.state.current_hunk = idx;
1124                    return;
1125                }
1126            }
1127        }
1128
1129        // If no changes applied, we're at hunk 0
1130        self.state.current_hunk = 0;
1131    }
1132
1133    /// Get the current hunk
1134    pub fn current_hunk(&self) -> Option<&crate::diff::Hunk> {
1135        self.diff.hunks.get(self.state.current_hunk)
1136    }
1137
1138    /// Get all hunks
1139    pub fn hunks(&self) -> &[crate::diff::Hunk] {
1140        &self.diff.hunks
1141    }
1142
1143    pub fn set_show_hunk_extent_while_stepping(&mut self, enabled: bool) {
1144        self.state.show_hunk_extent_while_stepping = enabled;
1145    }
1146
1147    // ==================== End Hunk Navigation ====================
1148
1149    /// Check if a change belongs to the hunk currently being animated
1150    fn is_change_in_animating_hunk(&self, change_id: usize) -> bool {
1151        self.state
1152            .animating_hunk
1153            .and_then(|hunk_idx| {
1154                self.hunk_index_for_change(change_id)
1155                    .map(|id| id == hunk_idx)
1156            })
1157            .unwrap_or(false)
1158    }
1159
1160    fn hunk_index_for_change(&self, change_id: usize) -> Option<usize> {
1161        let idx = self.change_to_index.get(change_id).copied().flatten()?;
1162        self.change_to_hunk.get(idx).copied().flatten()
1163    }
1164
1165    fn hunk_index_for_change_exact(&self, change_id: usize) -> Option<usize> {
1166        self.change_id_to_hunk_exact
1167            .get(change_id)
1168            .copied()
1169            .flatten()
1170    }
1171
1172    fn hunk_change_index_range(&self, hunk_idx: usize) -> Option<(usize, usize)> {
1173        self.hunk_change_ranges.get(hunk_idx).copied().flatten()
1174    }
1175
1176    fn hunk_change_index_range_exact(&self, hunk_idx: usize) -> Option<(usize, usize)> {
1177        self.hunk_change_ranges_exact
1178            .get(hunk_idx)
1179            .copied()
1180            .flatten()
1181    }
1182
1183    fn change_index(&self, change_id: usize) -> Option<usize> {
1184        self.change_to_index.get(change_id).copied().flatten()
1185    }
1186
1187    pub fn hunk_index_for_change_id(&self, change_id: usize) -> Option<usize> {
1188        self.hunk_index_for_change(change_id)
1189    }
1190
1191    pub fn hunk_index_for_change_id_exact(&self, change_id: usize) -> Option<usize> {
1192        self.hunk_index_for_change_exact(change_id)
1193    }
1194
1195    pub fn change_index_for(&self, change_id: usize) -> Option<usize> {
1196        self.change_index(change_id)
1197    }
1198
1199    pub fn hunk_step_range(&self, hunk_idx: usize) -> Option<(usize, usize)> {
1200        self.hunk_step_ranges
1201            .get(hunk_idx)
1202            .copied()
1203            .flatten()
1204            .map(|range| (range.start, range.len))
1205    }
1206
1207    /// Get the currently active change
1208    pub fn active_change(&self) -> Option<&Change> {
1209        self.state
1210            .active_change
1211            .and_then(|id| self.diff.changes.iter().find(|c| c.id == id))
1212    }
1213
1214    /// Get all changes with their application status
1215    pub fn changes_with_status(&self) -> Vec<(&Change, bool, bool)> {
1216        self.diff
1217            .changes
1218            .iter()
1219            .filter(|c| c.has_changes())
1220            .map(|c| {
1221                let applied = self.state.is_applied(c.id);
1222                let active = self.state.active_change == Some(c.id);
1223                (c, applied, active)
1224            })
1225            .collect()
1226    }
1227
1228    /// Reconstruct the content at the current step
1229    /// Returns lines with their change status (uses Idle frame for backwards compatibility)
1230    pub fn current_view(&self) -> Vec<ViewLine> {
1231        self.current_view_with_frame(AnimationFrame::Idle)
1232    }
1233
1234    /// Phase-aware view for word-level animation
1235    /// CLI should pass its current animation phase for proper fade animations
1236    pub fn current_view_with_frame(&self, frame: AnimationFrame) -> Vec<ViewLine> {
1237        self.view_for_changes(self.diff.changes.iter(), frame)
1238    }
1239
1240    pub fn view_line_for_change(
1241        &self,
1242        frame: AnimationFrame,
1243        change_id: usize,
1244    ) -> Option<ViewLine> {
1245        let change = self.diff.changes.iter().find(|c| c.id == change_id)?;
1246        let is_applied = self.state.is_applied(change_id);
1247        let is_in_hunk = self.is_change_in_animating_hunk(change_id);
1248        let is_active_change = self.state.active_change == Some(change_id);
1249        let is_active = is_active_change || is_in_hunk;
1250        let has_changes = change.has_changes();
1251        let scope_hunk = if self.state.last_nav_was_hunk {
1252            self.state
1253                .cursor_change
1254                .and_then(|id| self.hunk_index_for_change_exact(id))
1255                .unwrap_or(self.state.current_hunk)
1256        } else {
1257            self.state.current_hunk
1258        };
1259        let in_scope = if self.state.last_nav_was_hunk {
1260            let scope_range = if has_changes {
1261                self.hunk_change_index_range_exact(scope_hunk)
1262            } else {
1263                self.hunk_change_index_range(scope_hunk)
1264            };
1265            let idx = self.change_to_index.get(change_id).copied().flatten();
1266            match (scope_range, idx) {
1267                (Some((start, end)), Some(idx)) => idx >= start && idx <= end,
1268                _ => {
1269                    if has_changes {
1270                        self.hunk_index_for_change_exact(change_id) == Some(scope_hunk)
1271                    } else {
1272                        self.hunk_index_for_change(change_id) == Some(scope_hunk)
1273                    }
1274                }
1275            }
1276        } else {
1277            self.hunk_index_for_change(change_id) == Some(scope_hunk)
1278        };
1279        let show_hunk_extent = is_in_hunk
1280            || (in_scope
1281                && (self.state.last_nav_was_hunk || self.state.show_hunk_extent_while_stepping));
1282
1283        let primary_change_id = if self.state.cursor_change.is_some()
1284            && self.state.active_change.is_none()
1285            && self.state.step_direction == StepDirection::None
1286        {
1287            self.state.cursor_change
1288        } else if self.state.step_direction == StepDirection::Backward {
1289            self.state
1290                .applied_changes
1291                .last()
1292                .copied()
1293                .or(self.state.active_change)
1294        } else {
1295            self.state.active_change
1296        };
1297
1298        let is_primary_active =
1299            primary_change_id == Some(change_id) || (primary_change_id.is_none() && is_in_hunk);
1300
1301        if change.spans.len() > 1 {
1302            self.build_word_level_line(
1303                change,
1304                is_applied,
1305                is_active,
1306                is_active_change,
1307                is_primary_active,
1308                show_hunk_extent,
1309                frame,
1310            )
1311        } else {
1312            let span = change.spans.first()?;
1313            self.build_single_span_line(
1314                span,
1315                change_id,
1316                is_applied,
1317                is_active,
1318                is_active_change,
1319                is_primary_active,
1320                show_hunk_extent,
1321                frame,
1322            )
1323        }
1324    }
1325
1326    pub fn current_view_for_hunk(
1327        &self,
1328        frame: AnimationFrame,
1329        hunk_idx: usize,
1330        context_lines: usize,
1331    ) -> Vec<ViewLine> {
1332        if self.diff.changes.is_empty() {
1333            return Vec::new();
1334        }
1335        let Some(hunk) = self.diff.hunks.get(hunk_idx) else {
1336            return self.current_view_with_frame(frame);
1337        };
1338        let mut min_idx = None;
1339        let mut max_idx = None;
1340        for change_id in &hunk.change_ids {
1341            if let Some(idx) = self.change_index(*change_id) {
1342                min_idx = Some(min_idx.map_or(idx, |v: usize| v.min(idx)));
1343                max_idx = Some(max_idx.map_or(idx, |v: usize| v.max(idx)));
1344            }
1345        }
1346        let Some(min_idx) = min_idx else {
1347            return self.current_view_with_frame(frame);
1348        };
1349        let Some(max_idx) = max_idx else {
1350            return self.current_view_with_frame(frame);
1351        };
1352        let start = min_idx.saturating_sub(context_lines);
1353        let end = (max_idx + context_lines).min(self.diff.changes.len().saturating_sub(1));
1354        self.view_for_changes(self.diff.changes[start..=end].iter(), frame)
1355    }
1356
1357    pub fn current_view_for_change_window(
1358        &self,
1359        frame: AnimationFrame,
1360        change_id: usize,
1361        radius: usize,
1362    ) -> Vec<ViewLine> {
1363        let Some(idx) = self.change_index(change_id) else {
1364            return self.current_view_with_frame(frame);
1365        };
1366        if self.diff.changes.is_empty() {
1367            return Vec::new();
1368        }
1369        let start = idx.saturating_sub(radius);
1370        let end = (idx + radius).min(self.diff.changes.len().saturating_sub(1));
1371        self.view_for_changes(self.diff.changes[start..=end].iter(), frame)
1372    }
1373
1374    pub fn current_view_for_change_range(
1375        &self,
1376        frame: AnimationFrame,
1377        start: usize,
1378        end: usize,
1379    ) -> Vec<ViewLine> {
1380        if self.diff.changes.is_empty() {
1381            return Vec::new();
1382        }
1383        let start = start.min(self.diff.changes.len().saturating_sub(1));
1384        let end = end.min(self.diff.changes.len().saturating_sub(1));
1385        if start > end {
1386            return Vec::new();
1387        }
1388        self.view_for_changes(self.diff.changes[start..=end].iter(), frame)
1389    }
1390
1391    fn view_for_changes<'a, I>(&self, changes: I, frame: AnimationFrame) -> Vec<ViewLine>
1392    where
1393        I: IntoIterator<Item = &'a Change>,
1394    {
1395        let mut lines = Vec::new();
1396
1397        // Primary cursor destination: last applied change on backward, active_change on forward
1398        // Fallback to active_change at step 0 so cursor stays on fading line
1399        let primary_change_id = if self.state.cursor_change.is_some()
1400            && self.state.active_change.is_none()
1401            && self.state.step_direction == StepDirection::None
1402        {
1403            self.state.cursor_change
1404        } else if self.state.step_direction == StepDirection::Backward {
1405            self.state
1406                .applied_changes
1407                .last()
1408                .copied()
1409                .or(self.state.active_change)
1410        } else {
1411            self.state.active_change
1412        };
1413
1414        // Track if we've assigned a primary active line (for fallback when primary_change_id is None)
1415        let mut primary_assigned = false;
1416        let scope_hunk = if self.state.last_nav_was_hunk {
1417            self.state
1418                .cursor_change
1419                .and_then(|id| self.hunk_index_for_change_exact(id))
1420                .unwrap_or(self.state.current_hunk)
1421        } else {
1422            self.state.current_hunk
1423        };
1424        let scope_range_exact = if self.state.last_nav_was_hunk {
1425            self.hunk_change_index_range_exact(scope_hunk)
1426        } else {
1427            None
1428        };
1429        let scope_range_padded = if self.state.last_nav_was_hunk {
1430            self.hunk_change_index_range(scope_hunk)
1431        } else {
1432            None
1433        };
1434
1435        for change in changes {
1436            let is_applied = self.state.is_applied(change.id);
1437            let has_changes = change.has_changes();
1438            let use_exact = self.state.last_nav_was_hunk && has_changes;
1439
1440            // Primary active: cursor destination (decoupled from animation target on backward)
1441            let is_primary_active = primary_change_id == Some(change.id);
1442
1443            // Active: part of the animating hunk (for animation styling)
1444            let is_in_hunk = self.is_change_in_animating_hunk(change.id);
1445            let is_active_change = self.state.active_change == Some(change.id);
1446            // Active if: (1) the active_change, or (2) in animating hunk (lights up whole hunk during animation)
1447            let is_active = is_active_change || is_in_hunk;
1448            // Show extent marker if animating hunk OR (last nav was hunk AND change in current hunk)
1449            let scope_range = if use_exact {
1450                scope_range_exact
1451            } else {
1452                scope_range_padded
1453            };
1454            let change_idx =
1455                scope_range.and_then(|_| self.change_to_index.get(change.id).copied().flatten());
1456            let in_scope = if let (Some((start, end)), Some(idx)) = (scope_range, change_idx) {
1457                idx >= start && idx <= end
1458            } else if use_exact {
1459                self.hunk_index_for_change_exact(change.id) == Some(scope_hunk)
1460            } else {
1461                self.hunk_index_for_change(change.id) == Some(scope_hunk)
1462            };
1463            let show_hunk_extent = is_in_hunk
1464                || (in_scope
1465                    && (self.state.last_nav_was_hunk
1466                        || self.state.show_hunk_extent_while_stepping));
1467
1468            // Fallback: if primary_change_id is None but we're in an animating hunk,
1469            // first active line becomes primary
1470            let is_primary_active = is_primary_active
1471                || (!primary_assigned && is_in_hunk && primary_change_id.is_none());
1472
1473            if is_primary_active {
1474                primary_assigned = true;
1475            }
1476
1477            // Check if this is a word-level diff (multiple spans in one change that represents a line)
1478            let is_word_level = change.spans.len() > 1;
1479
1480            if is_word_level {
1481                // Combine all spans into a single line
1482                let line = self.build_word_level_line(
1483                    change,
1484                    is_applied,
1485                    is_active,
1486                    is_active_change,
1487                    is_primary_active,
1488                    show_hunk_extent,
1489                    frame,
1490                );
1491                if let Some(l) = line {
1492                    lines.push(l);
1493                }
1494            } else {
1495                // Single span - handle as before
1496                if let Some(span) = change.spans.first() {
1497                    if let Some(line) = self.build_single_span_line(
1498                        span,
1499                        change.id,
1500                        is_applied,
1501                        is_active,
1502                        is_active_change,
1503                        is_primary_active,
1504                        show_hunk_extent,
1505                        frame,
1506                    ) {
1507                        lines.push(line);
1508                    }
1509                }
1510            }
1511        }
1512
1513        lines
1514    }
1515
1516    /// Compute whether to show new content based on animation frame and direction.
1517    /// Used by both word-level and single-span line builders for consistent animation.
1518    fn compute_show_new(&self, is_applied: bool, frame: AnimationFrame) -> bool {
1519        // Guard: if direction is None, fall back to final state
1520        if self.state.step_direction == StepDirection::None {
1521            return is_applied;
1522        }
1523        match frame {
1524            AnimationFrame::Idle => is_applied,
1525            // Forward + FadeOut = show old (false), Backward + FadeOut = show new (true)
1526            AnimationFrame::FadeOut => self.state.step_direction == StepDirection::Backward,
1527            // Forward + FadeIn = show new (true), Backward + FadeIn = show old (false)
1528            AnimationFrame::FadeIn => self.state.step_direction != StepDirection::Backward,
1529        }
1530    }
1531
1532    #[allow(clippy::too_many_arguments)]
1533    fn build_word_level_line(
1534        &self,
1535        change: &Change,
1536        is_applied: bool,
1537        is_active: bool,
1538        is_active_change: bool,
1539        is_primary_active: bool,
1540        show_hunk_extent: bool,
1541        frame: AnimationFrame,
1542    ) -> Option<ViewLine> {
1543        let first_span = change.spans.first()?;
1544        let old_line = first_span.old_line;
1545        let new_line = first_span.new_line;
1546
1547        // Pre-scan to classify: does this change have old content, new content, or both?
1548        // Replace counts as both since it has old text and new_text.
1549        let has_old = change
1550            .spans
1551            .iter()
1552            .any(|s| matches!(s.kind, ChangeKind::Delete | ChangeKind::Replace));
1553        let has_new = change
1554            .spans
1555            .iter()
1556            .any(|s| matches!(s.kind, ChangeKind::Insert | ChangeKind::Replace));
1557
1558        // Build spans for the view line
1559        let mut view_spans = Vec::new();
1560        let mut content = String::new();
1561
1562        for span in &change.spans {
1563            // Phase-aware content and styling for active changes
1564            let (span_kind, text) = if is_active {
1565                // Determine show_new based on frame and change type:
1566                // - Idle: always snap to real applied state (no "phantom" content)
1567                // - FadeOut/FadeIn:
1568                //   - Mixed (has_old && has_new): phase-swap old/new
1569                //   - Insert-only: always show new (visible both phases)
1570                //   - Delete-only: always show old (visible both phases)
1571                let show_new = match frame {
1572                    AnimationFrame::Idle => self.compute_show_new(is_applied, frame),
1573                    _ => {
1574                        if has_old && has_new {
1575                            self.compute_show_new(is_applied, frame)
1576                        } else if has_new {
1577                            true // Insert-only: visible during animation
1578                        } else {
1579                            false // Delete-only: visible during animation
1580                        }
1581                    }
1582                };
1583
1584                match span.kind {
1585                    ChangeKind::Equal => (ViewSpanKind::Equal, span.text.clone()),
1586                    ChangeKind::Delete => {
1587                        if show_new {
1588                            continue; // Hide deletions when showing new state
1589                        } else {
1590                            (ViewSpanKind::PendingDelete, span.text.clone())
1591                        }
1592                    }
1593                    ChangeKind::Insert => {
1594                        if show_new {
1595                            (ViewSpanKind::PendingInsert, span.text.clone())
1596                        } else {
1597                            continue; // Hide insertions when showing old state
1598                        }
1599                    }
1600                    ChangeKind::Replace => {
1601                        if show_new {
1602                            (
1603                                ViewSpanKind::PendingInsert,
1604                                span.new_text.clone().unwrap_or_else(|| span.text.clone()),
1605                            )
1606                        } else {
1607                            (ViewSpanKind::PendingDelete, span.text.clone())
1608                        }
1609                    }
1610                }
1611            } else if is_applied {
1612                // Applied but not active - show final state
1613                let kind = match span.kind {
1614                    ChangeKind::Equal => ViewSpanKind::Equal,
1615                    ChangeKind::Delete => ViewSpanKind::Deleted,
1616                    ChangeKind::Insert => ViewSpanKind::Inserted,
1617                    ChangeKind::Replace => ViewSpanKind::Inserted,
1618                };
1619                let text = match span.kind {
1620                    ChangeKind::Delete => {
1621                        continue; // Don't include deleted text in the final content
1622                    }
1623                    ChangeKind::Replace => {
1624                        span.new_text.clone().unwrap_or_else(|| span.text.clone())
1625                    }
1626                    _ => span.text.clone(),
1627                };
1628                (kind, text)
1629            } else {
1630                // Not applied, not active - show original state
1631                match span.kind {
1632                    ChangeKind::Insert => {
1633                        continue; // Don't show pending inserts
1634                    }
1635                    _ => (ViewSpanKind::Equal, span.text.clone()),
1636                }
1637            };
1638
1639            content.push_str(&text);
1640            view_spans.push(ViewSpan {
1641                text,
1642                kind: span_kind,
1643            });
1644        }
1645
1646        // Defensive guard: don't emit blank lines if all spans were filtered
1647        if view_spans.is_empty() {
1648            return None;
1649        }
1650
1651        // Line kind - keep PendingModify for active word-level lines
1652        // (evolution view filters on LineKind::PendingDelete, so this prevents drops)
1653        let line_kind = if is_active {
1654            LineKind::PendingModify
1655        } else if is_applied {
1656            LineKind::Modified
1657        } else {
1658            LineKind::Context
1659        };
1660
1661        // Populate hunk metadata
1662        let hunk_index = self.hunk_index_for_change(change.id);
1663        let has_changes = change.has_changes();
1664
1665        Some(ViewLine {
1666            content,
1667            spans: view_spans,
1668            kind: line_kind,
1669            old_line,
1670            new_line,
1671            is_active,
1672            is_active_change,
1673            is_primary_active,
1674            show_hunk_extent,
1675            change_id: change.id,
1676            hunk_index,
1677            has_changes,
1678        })
1679    }
1680
1681    #[allow(clippy::too_many_arguments)]
1682    fn build_single_span_line(
1683        &self,
1684        span: &ChangeSpan,
1685        change_id: usize,
1686        is_applied: bool,
1687        is_active: bool,
1688        is_active_change: bool,
1689        is_primary_active: bool,
1690        show_hunk_extent: bool,
1691        frame: AnimationFrame,
1692    ) -> Option<ViewLine> {
1693        let view_span_kind;
1694        let line_kind;
1695        let content;
1696
1697        match span.kind {
1698            ChangeKind::Equal => {
1699                view_span_kind = ViewSpanKind::Equal;
1700                line_kind = LineKind::Context;
1701                content = span.text.clone();
1702            }
1703            ChangeKind::Delete => {
1704                // IMPORTANT: Check is_active FIRST so deletions animate before disappearing
1705                if is_active {
1706                    view_span_kind = ViewSpanKind::PendingDelete;
1707                    line_kind = LineKind::PendingDelete;
1708                    content = span.text.clone();
1709                } else if is_applied {
1710                    view_span_kind = ViewSpanKind::Deleted;
1711                    line_kind = LineKind::Deleted;
1712                    content = span.text.clone();
1713                } else {
1714                    view_span_kind = ViewSpanKind::Equal;
1715                    line_kind = LineKind::Context;
1716                    content = span.text.clone();
1717                }
1718            }
1719            ChangeKind::Insert => {
1720                // Check is_active first for animation
1721                if is_active {
1722                    view_span_kind = ViewSpanKind::PendingInsert;
1723                    line_kind = LineKind::PendingInsert;
1724                    content = span.text.clone();
1725                } else if is_applied {
1726                    view_span_kind = ViewSpanKind::Inserted;
1727                    line_kind = LineKind::Inserted;
1728                    content = span.text.clone();
1729                } else {
1730                    return None; // Don't show unapplied inserts
1731                }
1732            }
1733            ChangeKind::Replace => {
1734                // Phase-aware Replace: show old during FadeOut, new during FadeIn
1735                if is_active {
1736                    let show_new = self.compute_show_new(is_applied, frame);
1737                    if show_new {
1738                        view_span_kind = ViewSpanKind::PendingInsert;
1739                        content = span.new_text.clone().unwrap_or_else(|| span.text.clone());
1740                    } else {
1741                        view_span_kind = ViewSpanKind::PendingDelete;
1742                        content = span.text.clone();
1743                    }
1744                    line_kind = LineKind::PendingModify;
1745                } else if is_applied {
1746                    view_span_kind = ViewSpanKind::Inserted;
1747                    line_kind = LineKind::Modified;
1748                    content = span.new_text.clone().unwrap_or_else(|| span.text.clone());
1749                } else {
1750                    view_span_kind = ViewSpanKind::Equal;
1751                    line_kind = LineKind::Context;
1752                    content = span.text.clone();
1753                }
1754            }
1755        }
1756
1757        // Populate hunk metadata
1758        let hunk_index = self.hunk_index_for_change(change_id);
1759        let has_changes = !matches!(span.kind, ChangeKind::Equal);
1760
1761        Some(ViewLine {
1762            content: content.clone(),
1763            spans: vec![ViewSpan {
1764                text: content,
1765                kind: view_span_kind,
1766            }],
1767            kind: line_kind,
1768            old_line: span.old_line,
1769            new_line: span.new_line,
1770            is_active,
1771            is_active_change,
1772            is_primary_active,
1773            show_hunk_extent,
1774            change_id,
1775            hunk_index,
1776            has_changes,
1777        })
1778    }
1779
1780    /// Get old content
1781    pub fn old_content(&self) -> &str {
1782        self.old_content.as_ref()
1783    }
1784
1785    /// Get new content
1786    pub fn new_content(&self) -> &str {
1787        self.new_content.as_ref()
1788    }
1789}
1790
1791/// A styled span within a view line
1792#[derive(Debug, Clone)]
1793pub struct ViewSpan {
1794    pub text: String,
1795    pub kind: ViewSpanKind,
1796}
1797
1798/// The kind of span styling
1799#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1800pub enum ViewSpanKind {
1801    Equal,
1802    Inserted,
1803    Deleted,
1804    PendingInsert,
1805    PendingDelete,
1806}
1807
1808/// A line in the current view with its status
1809#[derive(Debug, Clone)]
1810pub struct ViewLine {
1811    /// Full content of the line
1812    pub content: String,
1813    /// Individual styled spans (for word-level highlighting)
1814    pub spans: Vec<ViewSpan>,
1815    /// Overall line kind
1816    pub kind: LineKind,
1817    pub old_line: Option<usize>,
1818    pub new_line: Option<usize>,
1819    /// Part of the active hunk (for animation styling)
1820    pub is_active: bool,
1821    /// The active change itself (not just part of a hunk preview)
1822    pub is_active_change: bool,
1823    /// The primary focus line within the hunk (for gutter marker)
1824    pub is_primary_active: bool,
1825    /// Show extent marker (true only during hunk navigation)
1826    pub show_hunk_extent: bool,
1827    /// ID of the change this line belongs to
1828    pub change_id: usize,
1829    /// Index of the hunk this line belongs to
1830    pub hunk_index: Option<usize>,
1831    /// True if the underlying change contains any non-equal spans
1832    pub has_changes: bool,
1833}
1834
1835/// The kind of line in the view
1836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1837pub enum LineKind {
1838    /// Unchanged context line
1839    Context,
1840    /// Line was inserted
1841    Inserted,
1842    /// Line was deleted
1843    Deleted,
1844    /// Line was modified
1845    Modified,
1846    /// Line is about to be deleted (active animation)
1847    PendingDelete,
1848    /// Line is about to be inserted (active animation)
1849    PendingInsert,
1850    /// Line is about to be modified (active animation)
1851    PendingModify,
1852}
1853
1854#[cfg(test)]
1855mod tests {
1856    use super::*;
1857    use crate::change::{Change, ChangeKind, ChangeSpan};
1858    use crate::diff::DiffEngine;
1859    use crate::diff::{DiffResult, Hunk};
1860    use std::sync::Arc;
1861
1862    fn build_manual_diff(
1863        changes: Vec<Change>,
1864        significant_changes: Vec<usize>,
1865        hunks: Vec<Hunk>,
1866    ) -> DiffResult {
1867        let mut insertions = 0usize;
1868        let mut deletions = 0usize;
1869        for change in &changes {
1870            for span in &change.spans {
1871                match span.kind {
1872                    ChangeKind::Insert => insertions += 1,
1873                    ChangeKind::Delete => deletions += 1,
1874                    ChangeKind::Replace => {
1875                        insertions += 1;
1876                        deletions += 1;
1877                    }
1878                    ChangeKind::Equal => {}
1879                }
1880            }
1881        }
1882        DiffResult {
1883            changes,
1884            significant_changes,
1885            hunks,
1886            insertions,
1887            deletions,
1888        }
1889    }
1890
1891    fn make_equal_change(id: usize) -> Change {
1892        let line = id + 1;
1893        Change::single(
1894            id,
1895            ChangeSpan::equal(format!("line{}", id)).with_lines(Some(line), Some(line)),
1896        )
1897    }
1898
1899    fn make_insert_change(id: usize) -> Change {
1900        let line = id + 1;
1901        Change::single(
1902            id,
1903            ChangeSpan::insert(format!("ins{}", id)).with_lines(None, Some(line)),
1904        )
1905    }
1906
1907    fn assert_applied_is_prefix(nav: &DiffNavigator) {
1908        let applied = &nav.state().applied_changes;
1909        let sig = &nav.diff.significant_changes;
1910        assert!(
1911            applied.len() <= sig.len(),
1912            "applied changes should not exceed significant changes"
1913        );
1914        assert_eq!(
1915            &sig[..applied.len()],
1916            applied.as_slice(),
1917            "applied changes should remain a prefix of significant_changes"
1918        );
1919    }
1920
1921    #[test]
1922    fn test_navigation() {
1923        let old = "foo\nbar\nbaz";
1924        let new = "foo\nqux\nbaz";
1925
1926        let engine = DiffEngine::new();
1927        let diff = engine.diff_strings(old, new);
1928        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
1929
1930        assert!(nav.state().is_at_start());
1931        assert!(!nav.state().is_at_end());
1932
1933        nav.next();
1934        assert!(!nav.state().is_at_start());
1935
1936        nav.goto_end();
1937        assert!(nav.state().is_at_end());
1938
1939        nav.prev();
1940        assert!(!nav.state().is_at_end());
1941
1942        nav.goto_start();
1943        assert!(nav.state().is_at_start());
1944    }
1945
1946    #[test]
1947    fn test_progress() {
1948        let old = "a\nb\nc\nd";
1949        let new = "a\nB\nC\nd";
1950
1951        let engine = DiffEngine::new();
1952        let diff = engine.diff_strings(old, new);
1953        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
1954
1955        assert_eq!(nav.state().progress(), 0.0);
1956
1957        nav.goto_end();
1958        assert_eq!(nav.state().progress(), 100.0);
1959    }
1960
1961    #[test]
1962    fn test_word_level_view() {
1963        let old = "const foo = 4";
1964        let new = "const bar = 5";
1965
1966        let engine = DiffEngine::new().with_word_level(true);
1967        let diff = engine.diff_strings(old, new);
1968        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
1969
1970        // At start, should show original line
1971        let view = nav.current_view();
1972        assert_eq!(view.len(), 1);
1973        assert_eq!(view[0].content, "const foo = 4");
1974
1975        // After applying change, should show new line
1976        nav.next();
1977        let view = nav.current_view();
1978        assert_eq!(view.len(), 1);
1979        assert_eq!(view[0].content, "const bar = 5");
1980    }
1981
1982    #[test]
1983    fn test_prev_hunk_animation_state() {
1984        // Setup: file with 2 hunks (changes separated by >3 unchanged lines)
1985        // Hunk proximity threshold is 3, so we need at least 4 unchanged lines between changes
1986        let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl";
1987        let new = "a\nB\nc\nd\ne\nf\ng\nh\ni\nj\nK\nl";
1988        //              ^ hunk 0 (line 2)              ^ hunk 1 (line 11)
1989
1990        let engine = DiffEngine::new();
1991        let diff = engine.diff_strings(old, new);
1992
1993        // Verify we have 2 hunks
1994        assert!(
1995            diff.hunks.len() >= 2,
1996            "Expected at least 2 hunks, got {}. Adjust fixture gap.",
1997            diff.hunks.len()
1998        );
1999
2000        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2001
2002        // Apply both hunks
2003        nav.next_hunk();
2004        nav.next_hunk();
2005        assert_eq!(nav.state().current_hunk, 1);
2006
2007        // Step back one hunk
2008        nav.prev_hunk();
2009
2010        // animating_hunk should point to hunk 1 (the one being removed)
2011        assert_eq!(
2012            nav.state().animating_hunk,
2013            Some(1),
2014            "animating_hunk should stay on the hunk being removed for fade animation"
2015        );
2016        // current_hunk should have moved to 0 (cursor position)
2017        assert_eq!(
2018            nav.state().current_hunk,
2019            0,
2020            "current_hunk should move to destination for status display"
2021        );
2022
2023        // View should mark hunk 1 changes as active
2024        let view = nav.current_view();
2025        let active_lines: Vec<_> = view.iter().filter(|l| l.is_active).collect();
2026        assert!(
2027            !active_lines.is_empty(),
2028            "Hunk changes should be marked active during fade"
2029        );
2030
2031        // After clearing, animating_hunk should be None
2032        nav.clear_active_change();
2033        assert_eq!(
2034            nav.state().animating_hunk,
2035            None,
2036            "animating_hunk should be cleared after animation completes"
2037        );
2038    }
2039
2040    #[test]
2041    fn test_prev_to_start_preserves_animation_state() {
2042        // Single hunk - stepping back lands on step 0
2043        // Animation state persists for fade-out, cleared by CLI after animation completes
2044        let old = "a\nb\nc";
2045        let new = "a\nB\nc";
2046
2047        let engine = DiffEngine::new();
2048        let diff = engine.diff_strings(old, new);
2049        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2050
2051        // Apply first change (no hunk preview)
2052        nav.next();
2053        assert_eq!(nav.state().current_step, 1);
2054
2055        // Step back to start
2056        nav.prev();
2057        assert!(nav.state().is_at_start());
2058
2059        // Animation state preserved for fade-out rendering
2060        assert!(
2061            nav.state().active_change.is_some(),
2062            "active_change preserved for fade-out"
2063        );
2064        assert_eq!(
2065            nav.state().step_direction,
2066            StepDirection::Backward,
2067            "step_direction should be Backward"
2068        );
2069
2070        // CLI calls clear_active_change() after animation completes
2071        nav.clear_active_change();
2072        assert_eq!(nav.state().active_change, None);
2073        assert_eq!(nav.state().animating_hunk, None);
2074        assert_eq!(nav.state().step_direction, StepDirection::None);
2075    }
2076
2077    #[test]
2078    fn test_prev_hunk_from_hunk_0_unapplies_changes() {
2079        // prev_hunk should unapply hunk 0 when it has applied changes
2080        let old = "a\nb\nc";
2081        let new = "a\nB\nc";
2082
2083        let engine = DiffEngine::new();
2084        let diff = engine.diff_strings(old, new);
2085        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2086
2087        // Apply first hunk
2088        nav.next_hunk();
2089        assert_eq!(nav.state().current_step, 1);
2090        assert_eq!(nav.state().current_hunk, 0);
2091
2092        // prev_hunk from hunk 0 should work (unapply the hunk)
2093        let moved = nav.prev_hunk();
2094        assert!(
2095            moved,
2096            "prev_hunk should succeed when hunk 0 has applied changes"
2097        );
2098        assert!(nav.state().is_at_start());
2099        assert_eq!(nav.state().current_step, 0);
2100
2101        // animating_hunk should be set for extent markers
2102        assert_eq!(
2103            nav.state().animating_hunk,
2104            Some(0),
2105            "animating_hunk should point to hunk 0 for fade animation"
2106        );
2107        assert_eq!(nav.state().step_direction, StepDirection::Backward);
2108
2109        // Calling prev_hunk again should return false (nothing to unapply)
2110        let moved_again = nav.prev_hunk();
2111        assert!(
2112            !moved_again,
2113            "prev_hunk should fail when hunk 0 has no applied changes"
2114        );
2115    }
2116
2117    #[test]
2118    fn test_backward_primary_marker_on_destination() {
2119        // Two hunks with >3 lines separation to ensure distinct hunks
2120        // Stepping back from hunk 1 should put primary on hunk 0's change (destination)
2121        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
2122        let new = "line1\nLINE2\nline3\nline4\nline5\nline6\nLINE7\nline8";
2123
2124        let engine = DiffEngine::new();
2125        let diff = engine.diff_strings(old, new);
2126        assert!(diff.hunks.len() >= 2, "Fixture must produce 2 hunks");
2127
2128        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2129
2130        // Apply both hunks
2131        nav.next_hunk(); // hunk 0 (LINE2)
2132        nav.next_hunk(); // hunk 1 (LINE7)
2133        assert_eq!(nav.state().current_step, 2);
2134
2135        // Step back from hunk 1
2136        nav.prev_hunk();
2137
2138        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2139
2140        // Find the lines
2141        let primary_lines: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
2142        let active_lines: Vec<_> = view.iter().filter(|l| l.is_active).collect();
2143
2144        // Exactly one primary line
2145        assert_eq!(primary_lines.len(), 1, "exactly one primary line");
2146
2147        // Fading hunk should have is_active lines
2148        assert!(!active_lines.is_empty(), "fading line should be active");
2149
2150        // Primary is on destination (hunk 0 = LINE2), not fading line (hunk 1 = LINE7)
2151        let primary = primary_lines[0];
2152        assert!(
2153            primary.content.contains("LINE2"),
2154            "primary marker should be on destination (hunk 0)"
2155        );
2156        assert!(
2157            !primary.content.contains("LINE7"),
2158            "primary marker should not be on fading line (hunk 1)"
2159        );
2160    }
2161
2162    #[test]
2163    fn test_forward_primary_marker_on_first_change() {
2164        // Multi-change hunk: next_hunk should put cursor on first change (top of hunk)
2165        // Hunk 0 has 2 consecutive changes so cursor position matters
2166        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
2167        let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nline7\nline8";
2168        //                 ^ first change  ^ second change (same hunk due to proximity)
2169
2170        let engine = DiffEngine::new();
2171        let diff = engine.diff_strings(old, new);
2172        assert!(
2173            !diff.hunks.is_empty(),
2174            "Fixture must produce at least 1 hunk"
2175        );
2176        assert!(
2177            diff.hunks[0].change_ids.len() >= 2,
2178            "Hunk 0 must have at least 2 changes for this test"
2179        );
2180
2181        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2182
2183        // Apply hunk 0
2184        nav.next_hunk();
2185
2186        // Use FadeIn to see new content (LINE2/LINE3)
2187        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
2188
2189        let primary_lines: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
2190        assert_eq!(primary_lines.len(), 1, "exactly one primary line");
2191
2192        // Primary should be on LINE2 (first change), not LINE3 (last change)
2193        let primary = primary_lines[0];
2194        assert!(
2195            primary.content.contains("LINE2"),
2196            "primary marker should be on first change (LINE2), got: {}",
2197            primary.content
2198        );
2199    }
2200
2201    #[test]
2202    fn test_step_down_after_next_hunk() {
2203        // After next_hunk lands at first change, step down should move to second change
2204        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
2205        let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nline7\nline8";
2206
2207        let engine = DiffEngine::new();
2208        let diff = engine.diff_strings(old, new);
2209        assert!(
2210            diff.hunks[0].change_ids.len() >= 2,
2211            "Hunk 0 must have at least 2 changes"
2212        );
2213
2214        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2215
2216        // next_hunk applies ALL changes (full preview), cursor at first
2217        nav.next_hunk();
2218        assert_eq!(
2219            nav.state().current_step,
2220            2,
2221            "Should apply all 2 changes after next_hunk"
2222        );
2223        assert!(nav.state().hunk_preview_mode, "Should be in preview mode");
2224
2225        // Step down dissolves preview: keeps first, applies second, cursor on second
2226        let moved = nav.next();
2227        assert!(moved, "next() should succeed");
2228        assert_eq!(
2229            nav.state().current_step,
2230            2,
2231            "Should still be at step 2 after dissolve"
2232        );
2233        assert!(!nav.state().hunk_preview_mode, "Should exit preview mode");
2234
2235        // Verify cursor is now on LINE3 (second change)
2236        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
2237        let primary = view.iter().find(|l| l.is_primary_active);
2238        assert!(primary.is_some(), "Should have primary line");
2239        assert!(
2240            primary.unwrap().content.contains("LINE3"),
2241            "Primary should be on LINE3 after stepping"
2242        );
2243    }
2244
2245    #[test]
2246    fn test_next_hunk_completes_current_then_lands_on_next() {
2247        // Two hunks separated by >3 lines. next_hunk on hunk 0 applies all,
2248        // then next_hunk moves to hunk 1 with full preview.
2249        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
2250        let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nLINE7\nLINE8";
2251        //         hunk 0: LINE2, LINE3          hunk 1: LINE7, LINE8
2252
2253        let engine = DiffEngine::new();
2254        let diff = engine.diff_strings(old, new);
2255        assert!(diff.hunks.len() >= 2, "Must have 2 hunks");
2256
2257        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2258
2259        // First next_hunk: apply all changes in hunk 0 (full preview)
2260        nav.next_hunk();
2261        assert_eq!(nav.state().current_hunk, 0);
2262        assert_eq!(
2263            nav.state().current_step,
2264            2,
2265            "Should apply all 2 changes in hunk 0"
2266        );
2267        assert!(nav.state().hunk_preview_mode);
2268
2269        // Second next_hunk: move to hunk 1 with full preview
2270        nav.next_hunk();
2271        assert_eq!(nav.state().current_hunk, 1, "Should be in hunk 1");
2272
2273        // All of hunk 0 (2 changes) + all of hunk 1 (2 changes) = 4 total
2274        assert_eq!(nav.state().current_step, 4, "Should have applied 4 changes");
2275        assert!(nav.state().hunk_preview_mode);
2276
2277        // Cursor should be on LINE7 (first change of hunk 1)
2278        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
2279        let primary = view.iter().find(|l| l.is_primary_active);
2280        assert!(primary.is_some());
2281        assert!(
2282            primary.unwrap().content.contains("LINE7"),
2283            "Cursor should be on LINE7"
2284        );
2285    }
2286
2287    #[test]
2288    fn test_next_hunk_on_last_hunk_stays_at_end() {
2289        // Single hunk with 2 changes. Calling next_hunk applies all changes.
2290        // Calling next_hunk again returns false (no next hunk).
2291        let old = "line1\nline2\nline3";
2292        let new = "line1\nLINE2\nLINE3";
2293
2294        let engine = DiffEngine::new();
2295        let diff = engine.diff_strings(old, new);
2296        assert_eq!(diff.hunks.len(), 1, "Must have exactly 1 hunk");
2297        assert_eq!(
2298            diff.hunks[0].change_ids.len(),
2299            2,
2300            "Hunk must have 2 changes"
2301        );
2302
2303        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2304
2305        // First next_hunk: apply all changes (full preview)
2306        let moved1 = nav.next_hunk();
2307        assert!(moved1);
2308        assert_eq!(nav.state().current_step, 2, "Should apply all 2 changes");
2309        assert!(nav.state().hunk_preview_mode);
2310
2311        // Second next_hunk: no next hunk, returns false
2312        let moved2 = nav.next_hunk();
2313        assert!(!moved2, "Should return false when no next hunk");
2314        assert_eq!(nav.state().current_step, 2, "Should still be at step 2");
2315    }
2316
2317    #[test]
2318    fn test_hunk_change_range_cached_for_large_hunk() {
2319        let old = "a\nb\nc\nd\ne";
2320        let new = "A\nB\nC\nD\nE";
2321
2322        let engine = DiffEngine::new();
2323        let diff = engine.diff_strings(old, new);
2324        assert_eq!(diff.hunks.len(), 1, "single hunk for full replace");
2325
2326        let nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), true);
2327        let range = nav.hunk_change_index_range(0);
2328        assert!(range.is_some(), "expected cached range for hunk 0");
2329        let (start, end) = range.unwrap();
2330        assert!(start <= end, "range should be valid");
2331    }
2332
2333    #[test]
2334    fn test_no_step_scope_prefers_exact_mapping_for_changes() {
2335        let changes = (0..8)
2336            .map(|id| {
2337                if id == 2 || id == 5 {
2338                    make_insert_change(id)
2339                } else {
2340                    make_equal_change(id)
2341                }
2342            })
2343            .collect::<Vec<_>>();
2344        let hunks = vec![
2345            Hunk {
2346                id: 0,
2347                change_ids: vec![2],
2348                old_start: None,
2349                new_start: Some(3),
2350                insertions: 1,
2351                deletions: 0,
2352            },
2353            Hunk {
2354                id: 1,
2355                change_ids: vec![5],
2356                old_start: None,
2357                new_start: Some(6),
2358                insertions: 1,
2359                deletions: 0,
2360            },
2361        ];
2362        let diff = build_manual_diff(changes, vec![2, 5], hunks);
2363        let mut nav = DiffNavigator::new(diff, Arc::from(""), Arc::from(""), true);
2364        nav.goto_end();
2365
2366        assert_eq!(
2367            nav.hunk_index_for_change_id(5),
2368            Some(0),
2369            "fixture should overlap padded range"
2370        );
2371        assert_eq!(
2372            nav.hunk_index_for_change_id_exact(5),
2373            Some(1),
2374            "exact mapping should point to hunk 1"
2375        );
2376
2377        nav.set_cursor_hunk(1, Some(5));
2378        nav.set_hunk_scope(true);
2379
2380        let view = nav.current_view_with_frame(AnimationFrame::Idle);
2381        let line_hunk_1 = view.iter().find(|l| l.change_id == 5).unwrap();
2382        let line_hunk_0 = view.iter().find(|l| l.change_id == 2).unwrap();
2383
2384        assert!(
2385            line_hunk_1.show_hunk_extent,
2386            "change line in scope hunk should show extent"
2387        );
2388        assert!(
2389            !line_hunk_0.show_hunk_extent,
2390            "change line outside scope hunk should not show extent"
2391        );
2392    }
2393
2394    #[test]
2395    fn test_no_step_scope_includes_context_lines() {
2396        let changes = (0..10)
2397            .map(|id| {
2398                if id == 4 {
2399                    make_insert_change(id)
2400                } else {
2401                    make_equal_change(id)
2402                }
2403            })
2404            .collect::<Vec<_>>();
2405        let hunks = vec![Hunk {
2406            id: 0,
2407            change_ids: vec![4],
2408            old_start: None,
2409            new_start: Some(5),
2410            insertions: 1,
2411            deletions: 0,
2412        }];
2413        let diff = build_manual_diff(changes, vec![4], hunks);
2414        let mut nav = DiffNavigator::new(diff, Arc::from(""), Arc::from(""), true);
2415        nav.goto_end();
2416
2417        nav.set_cursor_hunk(0, Some(4));
2418        nav.set_hunk_scope(true);
2419
2420        let view = nav.current_view_with_frame(AnimationFrame::Idle);
2421        let scope_range = nav
2422            .hunk_change_index_range(0)
2423            .expect("expected cached padded range");
2424
2425        for line in view.iter().filter(|l| !l.has_changes) {
2426            let idx = nav.change_index_for(line.change_id).unwrap();
2427            let in_range = idx >= scope_range.0 && idx <= scope_range.1;
2428            assert_eq!(
2429                line.show_hunk_extent, in_range,
2430                "context line {} scope mismatch",
2431                line.change_id
2432            );
2433        }
2434    }
2435
2436    #[test]
2437    fn test_markers_persist_within_hunk() {
2438        // Stepping within a hunk after next_hunk should preserve extent markers
2439        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
2440        let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nline7\nline8";
2441
2442        let engine = DiffEngine::new();
2443        let diff = engine.diff_strings(old, new);
2444
2445        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2446
2447        nav.next_hunk();
2448        assert!(
2449            nav.state().last_nav_was_hunk,
2450            "last_nav_was_hunk should be true after next_hunk"
2451        );
2452
2453        // Step within hunk (still in hunk 0)
2454        nav.next();
2455        assert!(
2456            nav.state().last_nav_was_hunk,
2457            "last_nav_was_hunk should persist within hunk"
2458        );
2459    }
2460
2461    #[test]
2462    fn test_markers_clear_on_hunk_exit() {
2463        // Stepping into a different hunk should clear extent markers
2464        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
2465        let new = "line1\nLINE2\nline3\nline4\nline5\nline6\nLINE7\nline8";
2466        //         hunk 0: LINE2                  hunk 1: LINE7
2467
2468        let engine = DiffEngine::new();
2469        let diff = engine.diff_strings(old, new);
2470        assert!(diff.hunks.len() >= 2, "Must have 2 hunks");
2471
2472        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2473
2474        // Apply hunk 0 via next_hunk
2475        nav.next_hunk();
2476        assert!(nav.state().last_nav_was_hunk);
2477
2478        // Step into hunk 1 via next() - should clear markers
2479        nav.next();
2480        assert!(
2481            !nav.state().last_nav_was_hunk,
2482            "Markers should clear when stepping into different hunk"
2483        );
2484    }
2485
2486    #[test]
2487    fn test_active_change_flag_in_hunk_preview() {
2488        // Single hunk with 2 changes: hunk preview should mark all lines active,
2489        // but only the first change is the active change.
2490        let old = "line1\nline2\nline3\n";
2491        let new = "LINE1\nLINE2\nline3\n";
2492
2493        let engine = DiffEngine::new();
2494        let diff = engine.diff_strings(old, new);
2495        assert_eq!(diff.hunks.len(), 1, "Expected a single hunk");
2496        assert!(
2497            diff.hunks[0].change_ids.len() >= 2,
2498            "Expected 2 changes in the hunk"
2499        );
2500
2501        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2502        nav.next_hunk();
2503
2504        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
2505        let active_changes: Vec<_> = view.iter().filter(|l| l.is_active_change).collect();
2506        let active_lines: Vec<_> = view.iter().filter(|l| l.is_active).collect();
2507
2508        assert_eq!(active_changes.len(), 1, "Only one active change expected");
2509        assert!(
2510            active_lines.len() >= 2,
2511            "All hunk lines should be active during preview"
2512        );
2513        assert!(
2514            active_changes[0].is_primary_active,
2515            "Active change should be primary"
2516        );
2517        assert!(
2518            active_changes[0].content.contains("LINE1"),
2519            "Active change should be the first change in the hunk"
2520        );
2521    }
2522
2523    #[test]
2524    fn test_word_level_phase_aware_mixed_change() {
2525        // Mixed change: has both old (foo, 4) and new (bar, 5) content
2526        // Should swap old/new at phase boundary
2527        let old = "const foo = 4";
2528        let new = "const bar = 5";
2529
2530        let engine = DiffEngine::new().with_word_level(true);
2531        let diff = engine.diff_strings(old, new);
2532        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2533
2534        // Apply the change (makes it active)
2535        nav.next();
2536        assert!(nav.state().active_change.is_some());
2537
2538        // FadeOut (forward): should show OLD content
2539        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2540        assert_eq!(view.len(), 1);
2541        assert_eq!(
2542            view[0].content, "const foo = 4",
2543            "FadeOut should show old content for mixed word-level change"
2544        );
2545
2546        // FadeIn (forward): should show NEW content
2547        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
2548        assert_eq!(view.len(), 1);
2549        assert_eq!(
2550            view[0].content, "const bar = 5",
2551            "FadeIn should show new content for mixed word-level change"
2552        );
2553
2554        // Idle: should show applied (new) content
2555        let view = nav.current_view_with_frame(AnimationFrame::Idle);
2556        assert_eq!(view.len(), 1);
2557        assert_eq!(
2558            view[0].content, "const bar = 5",
2559            "Idle should show applied (new) content"
2560        );
2561    }
2562
2563    #[test]
2564    fn test_word_level_insert_only_visible_both_phases() {
2565        // Insert-only change: should be visible across both FadeOut and FadeIn
2566        let old = "hello";
2567        let new = "hello world";
2568
2569        let engine = DiffEngine::new().with_word_level(true);
2570        let diff = engine.diff_strings(old, new);
2571        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2572
2573        // Apply the change
2574        nav.next();
2575
2576        // FadeOut: insert-only should still be visible (not hidden)
2577        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2578        assert_eq!(view.len(), 1);
2579        assert!(
2580            view[0].content.contains("world"),
2581            "Insert-only change should be visible during FadeOut, got: {}",
2582            view[0].content
2583        );
2584
2585        // FadeIn: should also be visible
2586        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
2587        assert_eq!(view.len(), 1);
2588        assert!(
2589            view[0].content.contains("world"),
2590            "Insert-only change should be visible during FadeIn, got: {}",
2591            view[0].content
2592        );
2593    }
2594
2595    #[test]
2596    fn test_word_level_delete_only_visible_both_phases() {
2597        // Delete-only change: should be visible across both FadeOut and FadeIn
2598        let old = "hello world";
2599        let new = "hello";
2600
2601        let engine = DiffEngine::new().with_word_level(true);
2602        let diff = engine.diff_strings(old, new);
2603        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2604
2605        // Apply the change
2606        nav.next();
2607
2608        // FadeOut: delete-only should still be visible (showing old content being deleted)
2609        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2610        assert_eq!(view.len(), 1);
2611        assert!(
2612            view[0].content.contains("world"),
2613            "Delete-only change should show deleted content during FadeOut, got: {}",
2614            view[0].content
2615        );
2616
2617        // FadeIn: should also show the deletion
2618        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
2619        assert_eq!(view.len(), 1);
2620        assert!(
2621            view[0].content.contains("world"),
2622            "Delete-only change should show deleted content during FadeIn, got: {}",
2623            view[0].content
2624        );
2625    }
2626
2627    #[test]
2628    fn test_word_level_insert_only_idle_respects_applied_state() {
2629        // Insert-only change: Idle frame should snap to real applied state
2630        // (no "phantom" inserts when animations are disabled)
2631        let old = "hello";
2632        let new = "hello world";
2633
2634        let engine = DiffEngine::new().with_word_level(true);
2635        let diff = engine.diff_strings(old, new);
2636        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2637
2638        // Apply then unapply
2639        nav.next();
2640        nav.prev();
2641
2642        // Idle should NOT contain "world" (it's unapplied)
2643        let view = nav.current_view_with_frame(AnimationFrame::Idle);
2644        assert_eq!(view.len(), 1);
2645        assert!(
2646            !view[0].content.contains("world"),
2647            "Idle should not show unapplied insert, got: {}",
2648            view[0].content
2649        );
2650    }
2651
2652    #[test]
2653    fn test_word_level_phase_aware_backward_with_multiple_changes() {
2654        // To properly test backward animation, we need multiple changes
2655        // so stepping back doesn't land on step 0 (which clears animation state)
2656        let old = "aaa\nconst foo = 4\nccc\nddd\neee\nfff\nggg\nconst bar = 8\niii\njjj";
2657        let new = "aaa\nconst bbb = 5\nccc\nddd\neee\nfff\nggg\nconst qux = 9\niii\njjj";
2658        //              ^ change 1 (word-level)              ^ change 2 (word-level)
2659
2660        let engine = DiffEngine::new().with_word_level(true);
2661        let diff = engine.diff_strings(old, new);
2662
2663        // Verify we have 2 changes
2664        assert!(
2665            diff.significant_changes.len() >= 2,
2666            "Expected at least 2 changes, got {}",
2667            diff.significant_changes.len()
2668        );
2669
2670        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2671
2672        // Apply both changes
2673        nav.next(); // step 1: first change applied
2674        nav.next(); // step 2: second change applied
2675        assert_eq!(nav.state().current_step, 2);
2676
2677        // Step back from step 2 to step 1 (second change is now active for backward animation)
2678        nav.prev();
2679        assert_eq!(nav.state().current_step, 1);
2680        assert_eq!(nav.state().step_direction, StepDirection::Backward);
2681        assert!(nav.state().active_change.is_some());
2682
2683        // Find the line that's active (should be line 8, word-level change being un-applied)
2684        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2685        let active_line = view.iter().find(|l| l.is_active);
2686        assert!(active_line.is_some(), "Should have an active line");
2687
2688        // Backward + FadeOut: should show NEW content (the content being removed)
2689        // For word-level, this means "const qux = 9"
2690        let active = active_line.unwrap();
2691        assert_eq!(
2692            active.content, "const qux = 9",
2693            "Backward FadeOut should show new content (being removed)"
2694        );
2695
2696        // Backward + FadeIn: should show OLD content (the content being restored)
2697        // For word-level, this means "const bar = 8"
2698        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
2699        let active_line = view.iter().find(|l| l.is_active);
2700        assert!(active_line.is_some(), "Should have an active line");
2701        let active = active_line.unwrap();
2702        assert_eq!(
2703            active.content, "const bar = 8",
2704            "Backward FadeIn should show old content (being restored)"
2705        );
2706    }
2707
2708    #[test]
2709    fn test_word_level_insert_only_backward_visible_both_phases() {
2710        // Insert-only word-level change should stay visible during backward animation
2711        // Test: "hello" -> "hello world" is insert-only (only adds " world")
2712        // We need 2 changes to avoid landing on step 0
2713        let old = "aaa\nhello\nccc\nddd\neee\nfff\nggg\nfoo\niii\njjj";
2714        let new = "aaa\nhello world\nccc\nddd\neee\nfff\nggg\nfoo bar\niii\njjj";
2715        //              ^ insert-only (add " world")       ^ insert-only (add " bar")
2716
2717        let engine = DiffEngine::new().with_word_level(true);
2718        let diff = engine.diff_strings(old, new);
2719
2720        assert!(
2721            diff.significant_changes.len() >= 2,
2722            "Need 2+ changes to avoid landing on step 0, got {}",
2723            diff.significant_changes.len()
2724        );
2725
2726        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2727
2728        // Apply both changes, then step back
2729        nav.next();
2730        nav.next();
2731        nav.prev();
2732
2733        assert_eq!(nav.state().step_direction, StepDirection::Backward);
2734        assert!(nav.state().active_change.is_some());
2735
2736        // FadeOut: insert-only should still be visible (shows the inserted content)
2737        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2738        let active_line = view.iter().find(|l| l.is_active);
2739        assert!(
2740            active_line.is_some(),
2741            "Should have an active line during FadeOut"
2742        );
2743        let active = active_line.unwrap();
2744        assert!(
2745            active.content.contains("bar"),
2746            "Backward FadeOut should show insert-only content, got: {}",
2747            active.content
2748        );
2749
2750        // FadeIn: should also show the inserted content (visible both phases)
2751        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
2752        let active_line = view.iter().find(|l| l.is_active);
2753        assert!(
2754            active_line.is_some(),
2755            "Should have an active line during FadeIn"
2756        );
2757        let active = active_line.unwrap();
2758        assert!(
2759            active.content.contains("bar"),
2760            "Backward FadeIn should show insert-only content, got: {}",
2761            active.content
2762        );
2763    }
2764
2765    #[test]
2766    fn test_word_level_active_line_kind_pending_modify() {
2767        // Active word-level lines should have LineKind::PendingModify
2768        // (evolution view filters on LineKind::PendingDelete, so this prevents drops)
2769        let old = "const foo = 4";
2770        let new = "const bar = 5";
2771
2772        let engine = DiffEngine::new().with_word_level(true);
2773        let diff = engine.diff_strings(old, new);
2774        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2775
2776        nav.next(); // active change
2777
2778        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2779        assert_eq!(view.len(), 1);
2780        assert_eq!(
2781            view[0].kind,
2782            LineKind::PendingModify,
2783            "Active word-level line should have PendingModify kind during FadeOut"
2784        );
2785
2786        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
2787        assert_eq!(view.len(), 1);
2788        assert_eq!(
2789            view[0].kind,
2790            LineKind::PendingModify,
2791            "Active word-level line should have PendingModify kind during FadeIn"
2792        );
2793    }
2794
2795    #[test]
2796    fn test_primary_active_unique_when_active_change_set() {
2797        // When active_change is set, exactly one line should be is_primary_active
2798        // and that line must also be is_active
2799        let old = "a\nb\nc\n";
2800        let new = "a\nB\nc\n";
2801        let diff = DiffEngine::new().diff_strings(old, new);
2802        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2803
2804        nav.next();
2805        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
2806
2807        let primary: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
2808        assert_eq!(
2809            primary.len(),
2810            1,
2811            "Exactly one line should be primary active"
2812        );
2813        assert!(
2814            primary[0].is_active,
2815            "Primary active line must also be is_active"
2816        );
2817    }
2818
2819    #[test]
2820    fn test_hunk_extent_not_primary() {
2821        // Multi-line hunk: all lines should be active during animation,
2822        // but only one should be is_primary_active (for gutter marker)
2823        let old = "a\nb\nc\nd\n";
2824        let new = "A\nb\nC\nd\n"; // A and C form one hunk (b is unchanged but within proximity)
2825        let diff = DiffEngine::new().diff_strings(old, new);
2826
2827        assert_eq!(
2828            diff.hunks.len(),
2829            1,
2830            "Fixture should produce a single hunk; adjust the unchanged gap if this fails"
2831        );
2832
2833        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2834
2835        nav.next_hunk();
2836        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2837
2838        // During animation, whole hunk lights up as active
2839        let active = view.iter().filter(|l| l.is_active).count();
2840        let extent = view.iter().filter(|l| l.show_hunk_extent).count();
2841        let primary = view.iter().filter(|l| l.is_primary_active).count();
2842
2843        assert!(
2844            active > 1,
2845            "Multiple lines should be active during animation, got {}",
2846            active
2847        );
2848        assert!(
2849            extent > 1,
2850            "Multiple lines should show extent markers, got {}",
2851            extent
2852        );
2853        assert_eq!(primary, 1, "Exactly one line should be primary active");
2854    }
2855
2856    #[test]
2857    fn test_hunk_extent_while_stepping() {
2858        // When stepping (not hunk-nav), extent markers should still show
2859        // if explicitly enabled by the UI.
2860        let old = "one\ntwo\nthree\nfour\n";
2861        let new = "ONE\nTWO\nthree\nfour\n";
2862        let diff = DiffEngine::new().diff_strings(old, new);
2863        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2864
2865        nav.next();
2866        nav.set_show_hunk_extent_while_stepping(true);
2867        let view = nav.current_view_with_frame(AnimationFrame::Idle);
2868
2869        let extent = view.iter().filter(|l| l.show_hunk_extent).count();
2870        assert!(
2871            extent > 0,
2872            "Extent markers should show while stepping when enabled"
2873        );
2874    }
2875
2876    #[test]
2877    fn test_applied_changes_stay_prefix_during_hunk_ops() {
2878        let old = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\n";
2879        let new = "l1\nL2\nL3\nl4\nl5\nl6\nl7\nl8\nL9\nl10\nl11\nl12\n";
2880        let diff = DiffEngine::new().diff_strings(old, new);
2881        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2882
2883        assert_applied_is_prefix(&nav);
2884
2885        nav.next_hunk();
2886        assert_applied_is_prefix(&nav);
2887
2888        nav.next();
2889        assert_applied_is_prefix(&nav);
2890
2891        nav.next_hunk();
2892        assert_applied_is_prefix(&nav);
2893
2894        nav.prev_hunk();
2895        assert_applied_is_prefix(&nav);
2896    }
2897
2898    #[test]
2899    fn test_applied_prefix_after_preview_step_down() {
2900        // One hunk with multiple changes so preview mode is used.
2901        let old = "a\nb\nc\n";
2902        let new = "A\nB\nc\n";
2903        let diff = DiffEngine::new().diff_strings(old, new);
2904        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2905
2906        nav.next_hunk();
2907        assert!(nav.state().hunk_preview_mode);
2908        assert_applied_is_prefix(&nav);
2909
2910        nav.next(); // dissolve preview for step down
2911        assert!(!nav.state().hunk_preview_mode);
2912        assert_applied_is_prefix(&nav);
2913    }
2914
2915    #[test]
2916    fn test_applied_prefix_after_preview_step_up() {
2917        // Two hunks so stepping up from preview exits to previous hunk.
2918        let old = "a\nb\nc\nd\ne\nf\ng\nh\n";
2919        let new = "A\nb\nc\nd\ne\nf\nG\nh\n";
2920        let diff = DiffEngine::new().diff_strings(old, new);
2921        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2922
2923        nav.next_hunk();
2924        assert!(nav.state().hunk_preview_mode);
2925        assert_applied_is_prefix(&nav);
2926
2927        nav.prev(); // dissolve preview for step up
2928        assert!(!nav.state().hunk_preview_mode);
2929        assert_applied_is_prefix(&nav);
2930    }
2931
2932    #[test]
2933    fn test_primary_active_fallback_when_active_change_none() {
2934        // When active_change is None but animating_hunk is set,
2935        // the first line in the hunk should become primary and be active
2936        let old = "a\nb\nc\n";
2937        let new = "A\nb\nC\n"; // two changes in same hunk
2938        let diff = DiffEngine::new().diff_strings(old, new);
2939        let mut nav = DiffNavigator::new(diff, Arc::from(old), Arc::from(new), false);
2940
2941        // Force animating hunk without active_change
2942        nav.state_mut().animating_hunk = Some(0);
2943        nav.state_mut().active_change = None;
2944        nav.state_mut().step_direction = StepDirection::Forward;
2945
2946        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2947
2948        let primary: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
2949        assert_eq!(
2950            primary.len(),
2951            1,
2952            "Exactly one line should be primary active"
2953        );
2954        assert!(
2955            primary[0].is_active,
2956            "Primary active line must also be is_active"
2957        );
2958
2959        // Verify it's the first active line in the view
2960        let first_active_idx = view.iter().position(|l| l.is_active).unwrap();
2961        let first_primary_idx = view.iter().position(|l| l.is_primary_active).unwrap();
2962        assert_eq!(
2963            first_active_idx, first_primary_idx,
2964            "First active line should be the primary line"
2965        );
2966    }
2967}