oyo_core/
step.rs

1//! Step-through navigation for diffs
2
3use crate::change::{Change, ChangeKind, ChangeSpan};
4use crate::diff::DiffResult;
5use serde::{Deserialize, Serialize};
6
7/// Direction of the last step action
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
9pub enum StepDirection {
10    #[default]
11    None,
12    Forward,
13    Backward,
14}
15
16/// Animation frame for phase-aware rendering
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum AnimationFrame {
19    #[default]
20    Idle,
21    FadeOut,
22    FadeIn,
23}
24
25/// The current state of stepping through a diff
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct StepState {
28    /// Current step index (0 = initial state, 1 = after first change applied, etc.)
29    pub current_step: usize,
30    /// Total number of steps (number of significant changes + 1 for initial state)
31    pub total_steps: usize,
32    /// IDs of changes that have been applied up to current step
33    pub applied_changes: Vec<usize>,
34    /// ID of the change being highlighted/animated at current step
35    pub active_change: Option<usize>,
36    /// Cursor change used for non-stepping navigation (does not imply animation)
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub cursor_change: Option<usize>,
39    /// Hunk currently being animated (distinct from cursor position in current_hunk)
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub animating_hunk: Option<usize>,
42    /// Direction of the last step action
43    pub step_direction: StepDirection,
44    /// Current hunk index (0-based)
45    pub current_hunk: usize,
46    /// Total number of hunks
47    pub total_hunks: usize,
48    /// True if the last navigation was a hunk navigation (for extent marker display)
49    #[serde(default)]
50    pub last_nav_was_hunk: bool,
51    /// True after hunkdown (full preview mode), cleared on first step
52    #[serde(default)]
53    pub hunk_preview_mode: bool,
54    /// True if preview was entered via hunkup (backward navigation)
55    #[serde(default)]
56    pub preview_from_backward: bool,
57    /// Show hunk extent markers while stepping (set by UI)
58    #[serde(default)]
59    pub show_hunk_extent_while_stepping: bool,
60}
61
62impl StepState {
63    pub fn new(total_changes: usize, total_hunks: usize) -> Self {
64        Self {
65            current_step: 0,
66            total_steps: total_changes + 1, // +1 for initial state
67            applied_changes: Vec::new(),
68            active_change: None,
69            cursor_change: None,
70            animating_hunk: None,
71            step_direction: StepDirection::None,
72            current_hunk: 0,
73            total_hunks,
74            last_nav_was_hunk: false,
75            hunk_preview_mode: false,
76            preview_from_backward: false,
77            show_hunk_extent_while_stepping: false,
78        }
79    }
80
81    /// Check if we're at the initial state (no changes applied)
82    pub fn is_at_start(&self) -> bool {
83        self.current_step == 0
84    }
85
86    /// Check if we're at the final state (all changes applied)
87    pub fn is_at_end(&self) -> bool {
88        self.current_step >= self.total_steps - 1
89    }
90
91    /// Get progress as a percentage
92    pub fn progress(&self) -> f64 {
93        if self.total_steps <= 1 {
94            return 100.0;
95        }
96        (self.current_step as f64 / (self.total_steps - 1) as f64) * 100.0
97    }
98}
99
100/// Navigator for stepping through diff changes
101pub struct DiffNavigator {
102    /// The diff result we're navigating
103    diff: DiffResult,
104    /// Current step state
105    state: StepState,
106    /// Original content (for reconstructing views)
107    old_content: String,
108    /// New content (for reconstructing views)
109    new_content: String,
110    /// Mapping from change ID to hunk index
111    change_to_hunk: std::collections::HashMap<usize, usize>,
112}
113
114impl DiffNavigator {
115    pub fn new(diff: DiffResult, old_content: String, new_content: String) -> Self {
116        let total_changes = diff.significant_changes.len();
117        let total_hunks = diff.hunks.len();
118
119        // Build change ID to hunk index mapping
120        let mut change_to_hunk = std::collections::HashMap::new();
121        for (hunk_idx, hunk) in diff.hunks.iter().enumerate() {
122            for &change_id in &hunk.change_ids {
123                change_to_hunk.insert(change_id, hunk_idx);
124            }
125        }
126
127        Self {
128            diff,
129            state: StepState::new(total_changes, total_hunks),
130            old_content,
131            new_content,
132            change_to_hunk,
133        }
134    }
135
136    /// Get the current step state
137    pub fn state(&self) -> &StepState {
138        &self.state
139    }
140
141    /// Get mutable access to step state (test-only)
142    #[cfg(test)]
143    pub fn state_mut(&mut self) -> &mut StepState {
144        &mut self.state
145    }
146
147    /// Replace the current step state (used to restore stepping mode)
148    pub fn set_state(&mut self, state: StepState) -> bool {
149        if state.total_steps != self.state.total_steps
150            || state.total_hunks != self.state.total_hunks
151        {
152            return false;
153        }
154        self.state = state;
155        true
156    }
157
158    /// Get the diff result
159    pub fn diff(&self) -> &DiffResult {
160        &self.diff
161    }
162
163    /// Set a non-animated cursor for classic (no-step) navigation.
164    pub fn set_cursor_hunk(&mut self, hunk_idx: usize, change_id: Option<usize>) {
165        if self.state.total_hunks > 0 {
166            self.state.current_hunk = hunk_idx.min(self.state.total_hunks - 1);
167        }
168        self.state.cursor_change = change_id;
169    }
170
171    /// Clear the non-animated cursor.
172    pub fn clear_cursor_change(&mut self) {
173        self.state.cursor_change = None;
174    }
175
176    /// Control hunk scope markers (extent indicators).
177    pub fn set_hunk_scope(&mut self, enabled: bool) {
178        self.state.last_nav_was_hunk = enabled;
179    }
180
181    /// Move to the next step
182    #[allow(clippy::should_implement_trait)]
183    pub fn next(&mut self) -> bool {
184        // Handle preview mode dissolution on first step
185        if self.state.hunk_preview_mode {
186            return self.dissolve_preview_for_step_down();
187        }
188
189        if self.state.is_at_end() {
190            return false;
191        }
192
193        let prev_hunk = self.state.current_hunk;
194
195        self.state.step_direction = StepDirection::Forward;
196        self.state.animating_hunk = None; // Clear hunk animation for single-step
197
198        // Get the next change to apply
199        let change_idx = self.state.current_step;
200        if change_idx < self.diff.significant_changes.len() {
201            let change_id = self.diff.significant_changes[change_idx];
202            self.state.applied_changes.push(change_id);
203            self.state.active_change = Some(change_id);
204
205            // Update current hunk
206            if let Some(hunk) = self.diff.hunk_for_change(change_id) {
207                self.state.current_hunk = hunk.id;
208            }
209        }
210
211        self.state.current_step += 1;
212
213        // Clear extent markers only when leaving hunk
214        if self.state.current_hunk != prev_hunk {
215            self.state.last_nav_was_hunk = false;
216        }
217
218        true
219    }
220
221    /// Dissolve preview mode on step down: keep first change, apply second
222    fn dissolve_preview_for_step_down(&mut self) -> bool {
223        if self.state.preview_from_backward {
224            self.state.hunk_preview_mode = false;
225            self.state.preview_from_backward = false;
226            return self.next();
227        }
228
229        let hunk = &self.diff.hunks[self.state.current_hunk];
230
231        // If hunk has only one change, stepping down exits the hunk
232        if hunk.change_ids.len() <= 1 {
233            self.state.hunk_preview_mode = false;
234            // Let normal next() handle moving to next change/hunk
235            return self.next();
236        }
237
238        // Keep only first change, unapply the rest
239        let first_change = hunk.change_ids[0];
240        let second_change = hunk.change_ids[1];
241
242        // Remove all changes in this hunk except the first
243        self.state
244            .applied_changes
245            .retain(|&id| !hunk.change_ids.contains(&id) || id == first_change);
246
247        // Apply second change
248        self.state.applied_changes.push(second_change);
249
250        // Update current_step to reflect actual applied changes
251        self.state.current_step = self.state.applied_changes.len();
252
253        self.state.active_change = Some(second_change);
254        self.state.step_direction = StepDirection::Forward;
255        self.state.hunk_preview_mode = false;
256        self.state.preview_from_backward = false;
257        self.state.animating_hunk = None;
258        // Preserve extent markers - we're still in the hunk scope
259        self.state.last_nav_was_hunk = true;
260
261        true
262    }
263
264    /// Move to the previous step
265    pub fn prev(&mut self) -> bool {
266        // Handle preview mode dissolution on first step up: exit hunk entirely
267        if self.state.hunk_preview_mode {
268            return self.dissolve_preview_for_step_up();
269        }
270
271        if self.state.is_at_start() {
272            return false;
273        }
274
275        let prev_hunk = self.state.current_hunk;
276
277        self.state.step_direction = StepDirection::Backward;
278        self.state.animating_hunk = None; // Clear hunk animation for single-step
279        self.state.current_step -= 1;
280
281        // Pop the change and set it as active for backward animation
282        if let Some(unapplied_change_id) = self.state.applied_changes.pop() {
283            self.state.active_change = Some(unapplied_change_id);
284
285            // Update current hunk based on last applied change
286            if let Some(&last_applied) = self.state.applied_changes.last() {
287                if let Some(hunk) = self.diff.hunk_for_change(last_applied) {
288                    self.state.current_hunk = hunk.id;
289                }
290            } else {
291                self.state.current_hunk = 0;
292            }
293        } else {
294            self.state.active_change = None;
295        }
296
297        // Reset hunk cursor when at start; animation state cleared by CLI after animation completes
298        if self.state.is_at_start() {
299            self.state.current_hunk = 0;
300        }
301
302        // Clear extent markers when leaving hunk or at step 0
303        if self.state.is_at_start() || self.state.current_hunk != prev_hunk {
304            self.state.last_nav_was_hunk = false;
305        }
306
307        true
308    }
309
310    /// Dissolve preview mode on step up: unapply all changes in hunk and exit
311    fn dissolve_preview_for_step_up(&mut self) -> bool {
312        if self.state.preview_from_backward {
313            self.state.hunk_preview_mode = false;
314            self.state.preview_from_backward = false;
315            return self.prev();
316        }
317
318        let current_hunk_idx = self.state.current_hunk;
319        let hunk = &self.diff.hunks[current_hunk_idx];
320
321        // Set animating hunk for backward fade animation
322        self.state.animating_hunk = Some(current_hunk_idx);
323
324        // Unapply all changes in this hunk
325        for &change_id in &hunk.change_ids {
326            self.state.applied_changes.retain(|&id| id != change_id);
327        }
328
329        // Update current_step to reflect actual applied changes
330        self.state.current_step = self.state.applied_changes.len();
331
332        // Move to previous hunk if possible
333        if current_hunk_idx > 0 {
334            self.state.current_hunk = current_hunk_idx - 1;
335        }
336
337        self.state.step_direction = StepDirection::Backward;
338        // Keep cursor logic aligned with the destination hunk (bottom-most applied change)
339        self.state.active_change = self.state.applied_changes.last().copied();
340        self.state.hunk_preview_mode = false;
341        self.state.preview_from_backward = false;
342        self.state.last_nav_was_hunk = false; // Exiting hunk
343
344        true
345    }
346
347    /// Clear animation state (called after animation completes or one-frame render)
348    /// For backward steps, keeps cursor on last applied change (destination)
349    pub fn clear_active_change(&mut self) {
350        if self.state.step_direction == StepDirection::Backward {
351            self.state.active_change = self.state.applied_changes.last().copied();
352        } else {
353            self.state.active_change = None;
354        }
355        self.state.animating_hunk = None;
356        self.state.step_direction = StepDirection::None;
357    }
358
359    /// Jump to a specific step
360    pub fn goto(&mut self, step: usize) {
361        let target_step = step.min(self.state.total_steps - 1);
362
363        // Reset to start
364        self.state.current_step = 0;
365        self.state.applied_changes.clear();
366        self.state.active_change = None;
367        self.state.cursor_change = None;
368        self.state.animating_hunk = None;
369        self.state.current_hunk = 0;
370        self.state.last_nav_was_hunk = false; // Clear hunk nav flag on goto
371        self.state.hunk_preview_mode = false; // Clear preview mode on goto
372        self.state.preview_from_backward = false;
373
374        // Apply changes up to target step
375        for _ in 0..target_step {
376            self.next();
377        }
378
379        // Update which hunk we're in
380        self.update_current_hunk();
381    }
382
383    /// Go to the start
384    pub fn goto_start(&mut self) {
385        self.goto(0);
386    }
387
388    /// Go to the end
389    pub fn goto_end(&mut self) {
390        self.goto(self.state.total_steps - 1);
391    }
392
393    // ==================== Hunk Navigation ====================
394
395    /// Move to the next hunk, applying ALL changes (full preview mode).
396    /// If current hunk is not started, applies all its changes with cursor at top.
397    /// If current hunk is partially/fully applied, completes it and moves to next hunk.
398    /// Returns true if moved, false if no movement possible
399    pub fn next_hunk(&mut self) -> bool {
400        if self.diff.hunks.is_empty() {
401            return false;
402        }
403
404        // Preserve preview mode in case we return false without doing anything
405        let was_in_preview = self.state.hunk_preview_mode;
406
407        self.state.step_direction = StepDirection::Forward;
408        self.state.hunk_preview_mode = false; // Will be set to true after applying
409        self.state.preview_from_backward = false;
410
411        let current_hunk = &self.diff.hunks[self.state.current_hunk];
412        let has_applied_in_current = current_hunk
413            .change_ids
414            .iter()
415            .any(|id| self.state.applied_changes.contains(id));
416
417        // If current hunk has no applied changes, apply ALL changes (full preview)
418        if !has_applied_in_current {
419            let mut moved = false;
420            for &change_id in &current_hunk.change_ids {
421                if !self.state.applied_changes.contains(&change_id) {
422                    self.state.applied_changes.push(change_id);
423                    self.state.current_step += 1;
424                    moved = true;
425                }
426            }
427
428            self.state.animating_hunk = Some(self.state.current_hunk);
429            self.state.active_change = current_hunk.change_ids.first().copied();
430
431            if moved {
432                self.state.last_nav_was_hunk = true;
433                self.state.hunk_preview_mode = true;
434                self.state.preview_from_backward = false;
435            }
436
437            return moved;
438        }
439
440        // Current hunk has applied changes - complete it first, exit preview mode
441        self.state.hunk_preview_mode = false;
442        self.state.preview_from_backward = false;
443        let mut completed_any = false;
444        for &change_id in &current_hunk.change_ids {
445            if !self.state.applied_changes.contains(&change_id) {
446                self.state.applied_changes.push(change_id);
447                self.state.current_step += 1;
448                completed_any = true;
449            }
450        }
451
452        // Move to next hunk
453        let next_hunk_idx = self.state.current_hunk + 1;
454        if next_hunk_idx >= self.diff.hunks.len() {
455            // No next hunk - if we completed current hunk, update state; otherwise return false
456            if completed_any {
457                self.state.animating_hunk = Some(self.state.current_hunk);
458                self.state.active_change = current_hunk.change_ids.last().copied();
459                self.state.last_nav_was_hunk = true;
460                return true;
461            }
462            // No movement, restore preview mode
463            self.state.hunk_preview_mode = was_in_preview;
464            return false;
465        }
466
467        let hunk = &self.diff.hunks[next_hunk_idx];
468
469        // Apply ALL changes of next hunk (full preview)
470        let mut moved = false;
471        for &change_id in &hunk.change_ids {
472            if !self.state.applied_changes.contains(&change_id) {
473                self.state.applied_changes.push(change_id);
474                self.state.current_step += 1;
475                moved = true;
476            }
477        }
478
479        self.state.animating_hunk = Some(next_hunk_idx);
480        self.state.active_change = hunk.change_ids.first().copied();
481        self.state.current_hunk = next_hunk_idx;
482
483        if moved {
484            self.state.last_nav_was_hunk = true;
485            self.state.hunk_preview_mode = true;
486            self.state.preview_from_backward = false;
487        }
488
489        moved
490    }
491
492    /// Move to the previous hunk, unapplying changes
493    /// Returns true if moved, false if nothing to unapply
494    pub fn prev_hunk(&mut self) -> bool {
495        if self.diff.hunks.is_empty() {
496            return false;
497        }
498
499        // Preserve preview mode in case we return false without doing anything
500        let was_in_preview = self.state.hunk_preview_mode;
501
502        // Clear preview mode
503        self.state.hunk_preview_mode = false;
504        self.state.preview_from_backward = false;
505
506        // On hunk 0, only proceed if there are applied changes to unapply
507        if self.state.current_hunk == 0 {
508            let hunk = &self.diff.hunks[0];
509            let has_applied = hunk
510                .change_ids
511                .iter()
512                .any(|id| self.state.applied_changes.contains(id));
513            if !has_applied {
514                // No movement, restore preview mode
515                self.state.hunk_preview_mode = was_in_preview;
516                return false;
517            }
518        }
519
520        self.state.step_direction = StepDirection::Backward;
521
522        // If we have applied changes in current hunk, unapply them
523        let current_hunk_idx = self.state.current_hunk;
524        let current_hunk = &self.diff.hunks[current_hunk_idx];
525        let mut moved = false;
526
527        // Unapply changes from current hunk that are applied
528        for &change_id in current_hunk.change_ids.iter().rev() {
529            if let Some(pos) = self
530                .state
531                .applied_changes
532                .iter()
533                .position(|&id| id == change_id)
534            {
535                self.state.applied_changes.remove(pos);
536                self.state.current_step = self.state.current_step.saturating_sub(1);
537                moved = true;
538            }
539        }
540
541        // Set animating hunk for whole-hunk animation (keep pointing at the hunk
542        // being removed so is_change_in_animating_hunk returns true during fade)
543        self.state.animating_hunk = Some(current_hunk_idx);
544        self.state.active_change = current_hunk.change_ids.first().copied();
545
546        // Move to previous hunk if current is now empty of applied changes
547        // (current_hunk tracks cursor position, animating_hunk tracks animation)
548        if moved {
549            // Check if we should move to previous hunk
550            let still_has_applied = current_hunk
551                .change_ids
552                .iter()
553                .any(|id| self.state.applied_changes.contains(id));
554            if !still_has_applied && self.state.current_hunk > 0 {
555                self.state.current_hunk -= 1;
556            }
557        } else if self.state.current_hunk > 0 {
558            // Nothing in current hunk was applied, try previous
559            self.state.current_hunk -= 1;
560            return self.prev_hunk();
561        }
562
563        // Don't overwrite animating_hunk here - let animation complete first.
564        // current_hunk already tracks cursor position for status display.
565
566        // Animation state cleared by CLI after animation completes
567
568        // Enter preview mode when we land in a previous hunk
569        if moved {
570            let entered_prev_hunk = self.state.current_hunk != current_hunk_idx;
571            if entered_prev_hunk {
572                self.state.hunk_preview_mode = true;
573                self.state.preview_from_backward = true;
574                self.state.last_nav_was_hunk = true;
575            } else {
576                // Set or clear extent markers based on whether we landed at step 0
577                self.state.last_nav_was_hunk = !self.state.is_at_start();
578            }
579        }
580
581        moved
582    }
583
584    /// Go to a specific hunk (0-indexed)
585    /// Applies all changes through target hunk (full preview mode).
586    /// Cursor lands at top of target hunk.
587    pub fn goto_hunk(&mut self, hunk_idx: usize) {
588        if hunk_idx >= self.diff.hunks.len() {
589            return;
590        }
591
592        // Reset to start
593        self.goto_start();
594
595        // Apply all changes for hunks before target
596        for idx in 0..hunk_idx {
597            let hunk = &self.diff.hunks[idx];
598            for &change_id in &hunk.change_ids {
599                self.state.applied_changes.push(change_id);
600                self.state.current_step += 1;
601            }
602        }
603
604        // Apply ALL changes of target hunk (full preview)
605        let hunk = &self.diff.hunks[hunk_idx];
606        for &change_id in &hunk.change_ids {
607            self.state.applied_changes.push(change_id);
608            self.state.current_step += 1;
609        }
610
611        self.state.current_hunk = hunk_idx;
612        self.state.animating_hunk = Some(hunk_idx);
613        self.state.active_change = hunk.change_ids.first().copied();
614        self.state.step_direction = StepDirection::Forward;
615        self.state.last_nav_was_hunk = true;
616        self.state.hunk_preview_mode = true;
617        self.state.preview_from_backward = false;
618    }
619
620    /// Jump to first change of current hunk, unapplying all but first
621    /// Returns true if moved, false if not inside a hunk or already at start
622    pub fn goto_hunk_start(&mut self) -> bool {
623        if self.diff.hunks.is_empty() {
624            return false;
625        }
626
627        let hunk = &self.diff.hunks[self.state.current_hunk];
628        let first_change = match hunk.change_ids.first() {
629            Some(&id) => id,
630            None => return false,
631        };
632
633        // Must have at least first change applied to be "inside" hunk
634        if !self.state.applied_changes.contains(&first_change) {
635            return false;
636        }
637
638        // Unapply all changes in this hunk except the first
639        let mut unapplied_any = false;
640        for &change_id in &hunk.change_ids[1..] {
641            if let Some(pos) = self
642                .state
643                .applied_changes
644                .iter()
645                .position(|&id| id == change_id)
646            {
647                self.state.applied_changes.remove(pos);
648                self.state.current_step = self.state.current_step.saturating_sub(1);
649                unapplied_any = true;
650            }
651        }
652
653        // No-op if already at start (nothing unapplied and cursor on first)
654        if !unapplied_any && self.state.active_change == Some(first_change) {
655            return false;
656        }
657
658        self.state.active_change = Some(first_change);
659        self.state.hunk_preview_mode = false;
660        self.state.preview_from_backward = false;
661        self.state.last_nav_was_hunk = true;
662        true
663    }
664
665    /// Jump to last change of current hunk, applying all changes in hunk
666    /// Returns true if moved, false if not inside a hunk or already at end
667    pub fn goto_hunk_end(&mut self) -> bool {
668        if self.diff.hunks.is_empty() {
669            return false;
670        }
671
672        let hunk = &self.diff.hunks[self.state.current_hunk];
673        let has_applied = hunk
674            .change_ids
675            .iter()
676            .any(|id| self.state.applied_changes.contains(id));
677        if !has_applied {
678            return false;
679        }
680
681        let last_change = hunk.change_ids.last().copied();
682
683        // Apply all unapplied changes in this hunk
684        for &change_id in &hunk.change_ids {
685            if !self.state.applied_changes.contains(&change_id) {
686                self.state.applied_changes.push(change_id);
687                self.state.current_step += 1;
688            }
689        }
690
691        // No-op if already at end (cursor on last)
692        if self.state.active_change == last_change {
693            return false;
694        }
695
696        self.state.active_change = last_change;
697        self.state.hunk_preview_mode = false;
698        self.state.preview_from_backward = false;
699        self.state.last_nav_was_hunk = true;
700        true
701    }
702
703    /// Update current hunk based on applied changes
704    pub fn update_current_hunk(&mut self) {
705        if self.diff.hunks.is_empty() {
706            return;
707        }
708
709        // Find which hunk contains the most recently applied change
710        if let Some(&last_applied) = self.state.applied_changes.last() {
711            for (idx, hunk) in self.diff.hunks.iter().enumerate() {
712                if hunk.change_ids.contains(&last_applied) {
713                    self.state.current_hunk = idx;
714                    return;
715                }
716            }
717        }
718
719        // If no changes applied, we're at hunk 0
720        self.state.current_hunk = 0;
721    }
722
723    /// Get the current hunk
724    pub fn current_hunk(&self) -> Option<&crate::diff::Hunk> {
725        self.diff.hunks.get(self.state.current_hunk)
726    }
727
728    /// Get all hunks
729    pub fn hunks(&self) -> &[crate::diff::Hunk] {
730        &self.diff.hunks
731    }
732
733    pub fn set_show_hunk_extent_while_stepping(&mut self, enabled: bool) {
734        self.state.show_hunk_extent_while_stepping = enabled;
735    }
736
737    // ==================== End Hunk Navigation ====================
738
739    /// Check if a change belongs to the hunk currently being animated
740    fn is_change_in_animating_hunk(&self, change_id: usize) -> bool {
741        if let Some(hunk_idx) = self.state.animating_hunk {
742            if let Some(hunk) = self.diff.hunks.get(hunk_idx) {
743                return hunk.change_ids.contains(&change_id);
744            }
745        }
746        false
747    }
748
749    /// Check if a change belongs to the current hunk (for persistent extent markers)
750    fn is_change_in_current_hunk(&self, change_id: usize) -> bool {
751        self.diff
752            .hunks
753            .get(self.state.current_hunk)
754            .map(|hunk| hunk.change_ids.contains(&change_id))
755            .unwrap_or(false)
756    }
757
758    /// Get the currently active change
759    pub fn active_change(&self) -> Option<&Change> {
760        self.state
761            .active_change
762            .and_then(|id| self.diff.changes.iter().find(|c| c.id == id))
763    }
764
765    /// Get all changes with their application status
766    pub fn changes_with_status(&self) -> Vec<(&Change, bool, bool)> {
767        self.diff
768            .changes
769            .iter()
770            .filter(|c| c.has_changes())
771            .map(|c| {
772                let applied = self.state.applied_changes.contains(&c.id);
773                let active = self.state.active_change == Some(c.id);
774                (c, applied, active)
775            })
776            .collect()
777    }
778
779    /// Reconstruct the content at the current step
780    /// Returns lines with their change status (uses Idle frame for backwards compatibility)
781    pub fn current_view(&self) -> Vec<ViewLine> {
782        self.current_view_with_frame(AnimationFrame::Idle)
783    }
784
785    /// Phase-aware view for word-level animation
786    /// CLI should pass its current animation phase for proper fade animations
787    pub fn current_view_with_frame(&self, frame: AnimationFrame) -> Vec<ViewLine> {
788        let mut lines = Vec::new();
789
790        // Primary cursor destination: last applied change on backward, active_change on forward
791        // Fallback to active_change at step 0 so cursor stays on fading line
792        let primary_change_id = if self.state.cursor_change.is_some()
793            && self.state.active_change.is_none()
794            && self.state.step_direction == StepDirection::None
795        {
796            self.state.cursor_change
797        } else if self.state.step_direction == StepDirection::Backward {
798            self.state
799                .applied_changes
800                .last()
801                .copied()
802                .or(self.state.active_change)
803        } else {
804            self.state.active_change
805        };
806
807        // Track if we've assigned a primary active line (for fallback when primary_change_id is None)
808        let mut primary_assigned = false;
809
810        for change in &self.diff.changes {
811            let is_applied = self.state.applied_changes.contains(&change.id);
812
813            // Primary active: cursor destination (decoupled from animation target on backward)
814            let is_primary_active = primary_change_id == Some(change.id);
815
816            // Active: part of the animating hunk (for animation styling)
817            let is_in_hunk = self.is_change_in_animating_hunk(change.id);
818            let is_active_change = self.state.active_change == Some(change.id);
819            // Active if: (1) the active_change, or (2) in animating hunk (lights up whole hunk during animation)
820            let is_active = is_active_change || is_in_hunk;
821            // Show extent marker if animating hunk OR (last nav was hunk AND change in current hunk)
822            let show_hunk_extent = is_in_hunk
823                || (self.is_change_in_current_hunk(change.id)
824                    && (self.state.last_nav_was_hunk
825                        || self.state.show_hunk_extent_while_stepping));
826
827            // Fallback: if primary_change_id is None but we're in an animating hunk,
828            // first active line becomes primary
829            let is_primary_active = is_primary_active
830                || (!primary_assigned && is_in_hunk && primary_change_id.is_none());
831
832            if is_primary_active {
833                primary_assigned = true;
834            }
835
836            // Check if this is a word-level diff (multiple spans in one change that represents a line)
837            let is_word_level = change.spans.len() > 1;
838
839            if is_word_level {
840                // Combine all spans into a single line
841                let line = self.build_word_level_line(
842                    change,
843                    is_applied,
844                    is_active,
845                    is_active_change,
846                    is_primary_active,
847                    show_hunk_extent,
848                    frame,
849                );
850                if let Some(l) = line {
851                    lines.push(l);
852                }
853            } else {
854                // Single span - handle as before
855                if let Some(span) = change.spans.first() {
856                    if let Some(line) = self.build_single_span_line(
857                        span,
858                        change.id,
859                        is_applied,
860                        is_active,
861                        is_active_change,
862                        is_primary_active,
863                        show_hunk_extent,
864                        frame,
865                    ) {
866                        lines.push(line);
867                    }
868                }
869            }
870        }
871
872        lines
873    }
874
875    /// Compute whether to show new content based on animation frame and direction.
876    /// Used by both word-level and single-span line builders for consistent animation.
877    fn compute_show_new(&self, is_applied: bool, frame: AnimationFrame) -> bool {
878        // Guard: if direction is None, fall back to final state
879        if self.state.step_direction == StepDirection::None {
880            return is_applied;
881        }
882        match frame {
883            AnimationFrame::Idle => is_applied,
884            // Forward + FadeOut = show old (false), Backward + FadeOut = show new (true)
885            AnimationFrame::FadeOut => self.state.step_direction == StepDirection::Backward,
886            // Forward + FadeIn = show new (true), Backward + FadeIn = show old (false)
887            AnimationFrame::FadeIn => self.state.step_direction != StepDirection::Backward,
888        }
889    }
890
891    #[allow(clippy::too_many_arguments)]
892    fn build_word_level_line(
893        &self,
894        change: &Change,
895        is_applied: bool,
896        is_active: bool,
897        is_active_change: bool,
898        is_primary_active: bool,
899        show_hunk_extent: bool,
900        frame: AnimationFrame,
901    ) -> Option<ViewLine> {
902        let first_span = change.spans.first()?;
903        let old_line = first_span.old_line;
904        let new_line = first_span.new_line;
905
906        // Pre-scan to classify: does this change have old content, new content, or both?
907        // Replace counts as both since it has old text and new_text.
908        let has_old = change
909            .spans
910            .iter()
911            .any(|s| matches!(s.kind, ChangeKind::Delete | ChangeKind::Replace));
912        let has_new = change
913            .spans
914            .iter()
915            .any(|s| matches!(s.kind, ChangeKind::Insert | ChangeKind::Replace));
916
917        // Build spans for the view line
918        let mut view_spans = Vec::new();
919        let mut content = String::new();
920
921        for span in &change.spans {
922            // Phase-aware content and styling for active changes
923            let (span_kind, text) = if is_active {
924                // Determine show_new based on frame and change type:
925                // - Idle: always snap to real applied state (no "phantom" content)
926                // - FadeOut/FadeIn:
927                //   - Mixed (has_old && has_new): phase-swap old/new
928                //   - Insert-only: always show new (visible both phases)
929                //   - Delete-only: always show old (visible both phases)
930                let show_new = match frame {
931                    AnimationFrame::Idle => self.compute_show_new(is_applied, frame),
932                    _ => {
933                        if has_old && has_new {
934                            self.compute_show_new(is_applied, frame)
935                        } else if has_new {
936                            true // Insert-only: visible during animation
937                        } else {
938                            false // Delete-only: visible during animation
939                        }
940                    }
941                };
942
943                match span.kind {
944                    ChangeKind::Equal => (ViewSpanKind::Equal, span.text.clone()),
945                    ChangeKind::Delete => {
946                        if show_new {
947                            continue; // Hide deletions when showing new state
948                        } else {
949                            (ViewSpanKind::PendingDelete, span.text.clone())
950                        }
951                    }
952                    ChangeKind::Insert => {
953                        if show_new {
954                            (ViewSpanKind::PendingInsert, span.text.clone())
955                        } else {
956                            continue; // Hide insertions when showing old state
957                        }
958                    }
959                    ChangeKind::Replace => {
960                        if show_new {
961                            (
962                                ViewSpanKind::PendingInsert,
963                                span.new_text.clone().unwrap_or_else(|| span.text.clone()),
964                            )
965                        } else {
966                            (ViewSpanKind::PendingDelete, span.text.clone())
967                        }
968                    }
969                }
970            } else if is_applied {
971                // Applied but not active - show final state
972                let kind = match span.kind {
973                    ChangeKind::Equal => ViewSpanKind::Equal,
974                    ChangeKind::Delete => ViewSpanKind::Deleted,
975                    ChangeKind::Insert => ViewSpanKind::Inserted,
976                    ChangeKind::Replace => ViewSpanKind::Inserted,
977                };
978                let text = match span.kind {
979                    ChangeKind::Delete => {
980                        continue; // Don't include deleted text in the final content
981                    }
982                    ChangeKind::Replace => {
983                        span.new_text.clone().unwrap_or_else(|| span.text.clone())
984                    }
985                    _ => span.text.clone(),
986                };
987                (kind, text)
988            } else {
989                // Not applied, not active - show original state
990                match span.kind {
991                    ChangeKind::Insert => {
992                        continue; // Don't show pending inserts
993                    }
994                    _ => (ViewSpanKind::Equal, span.text.clone()),
995                }
996            };
997
998            content.push_str(&text);
999            view_spans.push(ViewSpan {
1000                text,
1001                kind: span_kind,
1002            });
1003        }
1004
1005        // Defensive guard: don't emit blank lines if all spans were filtered
1006        if view_spans.is_empty() {
1007            return None;
1008        }
1009
1010        // Line kind - keep PendingModify for active word-level lines
1011        // (evolution view filters on LineKind::PendingDelete, so this prevents drops)
1012        let line_kind = if is_active {
1013            LineKind::PendingModify
1014        } else if is_applied {
1015            LineKind::Modified
1016        } else {
1017            LineKind::Context
1018        };
1019
1020        // Populate hunk metadata
1021        let hunk_index = self.change_to_hunk.get(&change.id).copied();
1022        let has_changes = change.has_changes();
1023
1024        Some(ViewLine {
1025            content,
1026            spans: view_spans,
1027            kind: line_kind,
1028            old_line,
1029            new_line,
1030            is_active,
1031            is_active_change,
1032            is_primary_active,
1033            show_hunk_extent,
1034            change_id: change.id,
1035            hunk_index,
1036            has_changes,
1037        })
1038    }
1039
1040    #[allow(clippy::too_many_arguments)]
1041    fn build_single_span_line(
1042        &self,
1043        span: &ChangeSpan,
1044        change_id: usize,
1045        is_applied: bool,
1046        is_active: bool,
1047        is_active_change: bool,
1048        is_primary_active: bool,
1049        show_hunk_extent: bool,
1050        frame: AnimationFrame,
1051    ) -> Option<ViewLine> {
1052        let view_span_kind;
1053        let line_kind;
1054        let content;
1055
1056        match span.kind {
1057            ChangeKind::Equal => {
1058                view_span_kind = ViewSpanKind::Equal;
1059                line_kind = LineKind::Context;
1060                content = span.text.clone();
1061            }
1062            ChangeKind::Delete => {
1063                // IMPORTANT: Check is_active FIRST so deletions animate before disappearing
1064                if is_active {
1065                    view_span_kind = ViewSpanKind::PendingDelete;
1066                    line_kind = LineKind::PendingDelete;
1067                    content = span.text.clone();
1068                } else if is_applied {
1069                    view_span_kind = ViewSpanKind::Deleted;
1070                    line_kind = LineKind::Deleted;
1071                    content = span.text.clone();
1072                } else {
1073                    view_span_kind = ViewSpanKind::Equal;
1074                    line_kind = LineKind::Context;
1075                    content = span.text.clone();
1076                }
1077            }
1078            ChangeKind::Insert => {
1079                // Check is_active first for animation
1080                if is_active {
1081                    view_span_kind = ViewSpanKind::PendingInsert;
1082                    line_kind = LineKind::PendingInsert;
1083                    content = span.text.clone();
1084                } else if is_applied {
1085                    view_span_kind = ViewSpanKind::Inserted;
1086                    line_kind = LineKind::Inserted;
1087                    content = span.text.clone();
1088                } else {
1089                    return None; // Don't show unapplied inserts
1090                }
1091            }
1092            ChangeKind::Replace => {
1093                // Phase-aware Replace: show old during FadeOut, new during FadeIn
1094                if is_active {
1095                    let show_new = self.compute_show_new(is_applied, frame);
1096                    if show_new {
1097                        view_span_kind = ViewSpanKind::PendingInsert;
1098                        content = span.new_text.clone().unwrap_or_else(|| span.text.clone());
1099                    } else {
1100                        view_span_kind = ViewSpanKind::PendingDelete;
1101                        content = span.text.clone();
1102                    }
1103                    line_kind = LineKind::PendingModify;
1104                } else if is_applied {
1105                    view_span_kind = ViewSpanKind::Inserted;
1106                    line_kind = LineKind::Modified;
1107                    content = span.new_text.clone().unwrap_or_else(|| span.text.clone());
1108                } else {
1109                    view_span_kind = ViewSpanKind::Equal;
1110                    line_kind = LineKind::Context;
1111                    content = span.text.clone();
1112                }
1113            }
1114        }
1115
1116        // Populate hunk metadata
1117        let hunk_index = self.change_to_hunk.get(&change_id).copied();
1118        let has_changes = !matches!(span.kind, ChangeKind::Equal);
1119
1120        Some(ViewLine {
1121            content: content.clone(),
1122            spans: vec![ViewSpan {
1123                text: content,
1124                kind: view_span_kind,
1125            }],
1126            kind: line_kind,
1127            old_line: span.old_line,
1128            new_line: span.new_line,
1129            is_active,
1130            is_active_change,
1131            is_primary_active,
1132            show_hunk_extent,
1133            change_id,
1134            hunk_index,
1135            has_changes,
1136        })
1137    }
1138
1139    /// Get old content
1140    pub fn old_content(&self) -> &str {
1141        &self.old_content
1142    }
1143
1144    /// Get new content
1145    pub fn new_content(&self) -> &str {
1146        &self.new_content
1147    }
1148}
1149
1150/// A styled span within a view line
1151#[derive(Debug, Clone)]
1152pub struct ViewSpan {
1153    pub text: String,
1154    pub kind: ViewSpanKind,
1155}
1156
1157/// The kind of span styling
1158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1159pub enum ViewSpanKind {
1160    Equal,
1161    Inserted,
1162    Deleted,
1163    PendingInsert,
1164    PendingDelete,
1165}
1166
1167/// A line in the current view with its status
1168#[derive(Debug, Clone)]
1169pub struct ViewLine {
1170    /// Full content of the line
1171    pub content: String,
1172    /// Individual styled spans (for word-level highlighting)
1173    pub spans: Vec<ViewSpan>,
1174    /// Overall line kind
1175    pub kind: LineKind,
1176    pub old_line: Option<usize>,
1177    pub new_line: Option<usize>,
1178    /// Part of the active hunk (for animation styling)
1179    pub is_active: bool,
1180    /// The active change itself (not just part of a hunk preview)
1181    pub is_active_change: bool,
1182    /// The primary focus line within the hunk (for gutter marker)
1183    pub is_primary_active: bool,
1184    /// Show extent marker (true only during hunk navigation)
1185    pub show_hunk_extent: bool,
1186    /// ID of the change this line belongs to
1187    pub change_id: usize,
1188    /// Index of the hunk this line belongs to
1189    pub hunk_index: Option<usize>,
1190    /// True if the underlying change contains any non-equal spans
1191    pub has_changes: bool,
1192}
1193
1194/// The kind of line in the view
1195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1196pub enum LineKind {
1197    /// Unchanged context line
1198    Context,
1199    /// Line was inserted
1200    Inserted,
1201    /// Line was deleted
1202    Deleted,
1203    /// Line was modified
1204    Modified,
1205    /// Line is about to be deleted (active animation)
1206    PendingDelete,
1207    /// Line is about to be inserted (active animation)
1208    PendingInsert,
1209    /// Line is about to be modified (active animation)
1210    PendingModify,
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215    use super::*;
1216    use crate::diff::DiffEngine;
1217
1218    #[test]
1219    fn test_navigation() {
1220        let old = "foo\nbar\nbaz";
1221        let new = "foo\nqux\nbaz";
1222
1223        let engine = DiffEngine::new();
1224        let diff = engine.diff_strings(old, new);
1225        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1226
1227        assert!(nav.state().is_at_start());
1228        assert!(!nav.state().is_at_end());
1229
1230        nav.next();
1231        assert!(!nav.state().is_at_start());
1232
1233        nav.goto_end();
1234        assert!(nav.state().is_at_end());
1235
1236        nav.prev();
1237        assert!(!nav.state().is_at_end());
1238
1239        nav.goto_start();
1240        assert!(nav.state().is_at_start());
1241    }
1242
1243    #[test]
1244    fn test_progress() {
1245        let old = "a\nb\nc\nd";
1246        let new = "a\nB\nC\nd";
1247
1248        let engine = DiffEngine::new();
1249        let diff = engine.diff_strings(old, new);
1250        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1251
1252        assert_eq!(nav.state().progress(), 0.0);
1253
1254        nav.goto_end();
1255        assert_eq!(nav.state().progress(), 100.0);
1256    }
1257
1258    #[test]
1259    fn test_word_level_view() {
1260        let old = "const foo = 4";
1261        let new = "const bar = 5";
1262
1263        let engine = DiffEngine::new().with_word_level(true);
1264        let diff = engine.diff_strings(old, new);
1265        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1266
1267        // At start, should show original line
1268        let view = nav.current_view();
1269        assert_eq!(view.len(), 1);
1270        assert_eq!(view[0].content, "const foo = 4");
1271
1272        // After applying change, should show new line
1273        nav.next();
1274        let view = nav.current_view();
1275        assert_eq!(view.len(), 1);
1276        assert_eq!(view[0].content, "const bar = 5");
1277    }
1278
1279    #[test]
1280    fn test_prev_hunk_animation_state() {
1281        // Setup: file with 2 hunks (changes separated by >3 unchanged lines)
1282        // Hunk proximity threshold is 3, so we need at least 4 unchanged lines between changes
1283        let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl";
1284        let new = "a\nB\nc\nd\ne\nf\ng\nh\ni\nj\nK\nl";
1285        //              ^ hunk 0 (line 2)              ^ hunk 1 (line 11)
1286
1287        let engine = DiffEngine::new();
1288        let diff = engine.diff_strings(old, new);
1289
1290        // Verify we have 2 hunks
1291        assert!(
1292            diff.hunks.len() >= 2,
1293            "Expected at least 2 hunks, got {}. Adjust fixture gap.",
1294            diff.hunks.len()
1295        );
1296
1297        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1298
1299        // Apply both hunks
1300        nav.next_hunk();
1301        nav.next_hunk();
1302        assert_eq!(nav.state().current_hunk, 1);
1303
1304        // Step back one hunk
1305        nav.prev_hunk();
1306
1307        // animating_hunk should point to hunk 1 (the one being removed)
1308        assert_eq!(
1309            nav.state().animating_hunk,
1310            Some(1),
1311            "animating_hunk should stay on the hunk being removed for fade animation"
1312        );
1313        // current_hunk should have moved to 0 (cursor position)
1314        assert_eq!(
1315            nav.state().current_hunk,
1316            0,
1317            "current_hunk should move to destination for status display"
1318        );
1319
1320        // View should mark hunk 1 changes as active
1321        let view = nav.current_view();
1322        let active_lines: Vec<_> = view.iter().filter(|l| l.is_active).collect();
1323        assert!(
1324            !active_lines.is_empty(),
1325            "Hunk changes should be marked active during fade"
1326        );
1327
1328        // After clearing, animating_hunk should be None
1329        nav.clear_active_change();
1330        assert_eq!(
1331            nav.state().animating_hunk,
1332            None,
1333            "animating_hunk should be cleared after animation completes"
1334        );
1335    }
1336
1337    #[test]
1338    fn test_prev_to_start_preserves_animation_state() {
1339        // Single hunk - stepping back lands on step 0
1340        // Animation state persists for fade-out, cleared by CLI after animation completes
1341        let old = "a\nb\nc";
1342        let new = "a\nB\nc";
1343
1344        let engine = DiffEngine::new();
1345        let diff = engine.diff_strings(old, new);
1346        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1347
1348        // Apply first change (no hunk preview)
1349        nav.next();
1350        assert_eq!(nav.state().current_step, 1);
1351
1352        // Step back to start
1353        nav.prev();
1354        assert!(nav.state().is_at_start());
1355
1356        // Animation state preserved for fade-out rendering
1357        assert!(
1358            nav.state().active_change.is_some(),
1359            "active_change preserved for fade-out"
1360        );
1361        assert_eq!(
1362            nav.state().step_direction,
1363            StepDirection::Backward,
1364            "step_direction should be Backward"
1365        );
1366
1367        // CLI calls clear_active_change() after animation completes
1368        nav.clear_active_change();
1369        assert_eq!(nav.state().active_change, None);
1370        assert_eq!(nav.state().animating_hunk, None);
1371        assert_eq!(nav.state().step_direction, StepDirection::None);
1372    }
1373
1374    #[test]
1375    fn test_prev_hunk_from_hunk_0_unapplies_changes() {
1376        // prev_hunk should unapply hunk 0 when it has applied changes
1377        let old = "a\nb\nc";
1378        let new = "a\nB\nc";
1379
1380        let engine = DiffEngine::new();
1381        let diff = engine.diff_strings(old, new);
1382        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1383
1384        // Apply first hunk
1385        nav.next_hunk();
1386        assert_eq!(nav.state().current_step, 1);
1387        assert_eq!(nav.state().current_hunk, 0);
1388
1389        // prev_hunk from hunk 0 should work (unapply the hunk)
1390        let moved = nav.prev_hunk();
1391        assert!(
1392            moved,
1393            "prev_hunk should succeed when hunk 0 has applied changes"
1394        );
1395        assert!(nav.state().is_at_start());
1396        assert_eq!(nav.state().current_step, 0);
1397
1398        // animating_hunk should be set for extent markers
1399        assert_eq!(
1400            nav.state().animating_hunk,
1401            Some(0),
1402            "animating_hunk should point to hunk 0 for fade animation"
1403        );
1404        assert_eq!(nav.state().step_direction, StepDirection::Backward);
1405
1406        // Calling prev_hunk again should return false (nothing to unapply)
1407        let moved_again = nav.prev_hunk();
1408        assert!(
1409            !moved_again,
1410            "prev_hunk should fail when hunk 0 has no applied changes"
1411        );
1412    }
1413
1414    #[test]
1415    fn test_backward_primary_marker_on_destination() {
1416        // Two hunks with >3 lines separation to ensure distinct hunks
1417        // Stepping back from hunk 1 should put primary on hunk 0's change (destination)
1418        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1419        let new = "line1\nLINE2\nline3\nline4\nline5\nline6\nLINE7\nline8";
1420
1421        let engine = DiffEngine::new();
1422        let diff = engine.diff_strings(old, new);
1423        assert!(diff.hunks.len() >= 2, "Fixture must produce 2 hunks");
1424
1425        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1426
1427        // Apply both hunks
1428        nav.next_hunk(); // hunk 0 (LINE2)
1429        nav.next_hunk(); // hunk 1 (LINE7)
1430        assert_eq!(nav.state().current_step, 2);
1431
1432        // Step back from hunk 1
1433        nav.prev_hunk();
1434
1435        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1436
1437        // Find the lines
1438        let primary_lines: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
1439        let active_lines: Vec<_> = view.iter().filter(|l| l.is_active).collect();
1440
1441        // Exactly one primary line
1442        assert_eq!(primary_lines.len(), 1, "exactly one primary line");
1443
1444        // Fading hunk should have is_active lines
1445        assert!(active_lines.len() >= 1, "fading line should be active");
1446
1447        // Primary is on destination (hunk 0 = LINE2), not fading line (hunk 1 = LINE7)
1448        let primary = primary_lines[0];
1449        assert!(
1450            primary.content.contains("LINE2"),
1451            "primary marker should be on destination (hunk 0)"
1452        );
1453        assert!(
1454            !primary.content.contains("LINE7"),
1455            "primary marker should not be on fading line (hunk 1)"
1456        );
1457    }
1458
1459    #[test]
1460    fn test_forward_primary_marker_on_first_change() {
1461        // Multi-change hunk: next_hunk should put cursor on first change (top of hunk)
1462        // Hunk 0 has 2 consecutive changes so cursor position matters
1463        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1464        let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nline7\nline8";
1465        //                 ^ first change  ^ second change (same hunk due to proximity)
1466
1467        let engine = DiffEngine::new();
1468        let diff = engine.diff_strings(old, new);
1469        assert!(
1470            !diff.hunks.is_empty(),
1471            "Fixture must produce at least 1 hunk"
1472        );
1473        assert!(
1474            diff.hunks[0].change_ids.len() >= 2,
1475            "Hunk 0 must have at least 2 changes for this test"
1476        );
1477
1478        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1479
1480        // Apply hunk 0
1481        nav.next_hunk();
1482
1483        // Use FadeIn to see new content (LINE2/LINE3)
1484        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1485
1486        let primary_lines: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
1487        assert_eq!(primary_lines.len(), 1, "exactly one primary line");
1488
1489        // Primary should be on LINE2 (first change), not LINE3 (last change)
1490        let primary = primary_lines[0];
1491        assert!(
1492            primary.content.contains("LINE2"),
1493            "primary marker should be on first change (LINE2), got: {}",
1494            primary.content
1495        );
1496    }
1497
1498    #[test]
1499    fn test_step_down_after_next_hunk() {
1500        // After next_hunk lands at first change, step down should move to second change
1501        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1502        let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nline7\nline8";
1503
1504        let engine = DiffEngine::new();
1505        let diff = engine.diff_strings(old, new);
1506        assert!(
1507            diff.hunks[0].change_ids.len() >= 2,
1508            "Hunk 0 must have at least 2 changes"
1509        );
1510
1511        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1512
1513        // next_hunk applies ALL changes (full preview), cursor at first
1514        nav.next_hunk();
1515        assert_eq!(
1516            nav.state().current_step,
1517            2,
1518            "Should apply all 2 changes after next_hunk"
1519        );
1520        assert!(nav.state().hunk_preview_mode, "Should be in preview mode");
1521
1522        // Step down dissolves preview: keeps first, applies second, cursor on second
1523        let moved = nav.next();
1524        assert!(moved, "next() should succeed");
1525        assert_eq!(
1526            nav.state().current_step,
1527            2,
1528            "Should still be at step 2 after dissolve"
1529        );
1530        assert!(!nav.state().hunk_preview_mode, "Should exit preview mode");
1531
1532        // Verify cursor is now on LINE3 (second change)
1533        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1534        let primary = view.iter().find(|l| l.is_primary_active);
1535        assert!(primary.is_some(), "Should have primary line");
1536        assert!(
1537            primary.unwrap().content.contains("LINE3"),
1538            "Primary should be on LINE3 after stepping"
1539        );
1540    }
1541
1542    #[test]
1543    fn test_next_hunk_completes_current_then_lands_on_next() {
1544        // Two hunks separated by >3 lines. next_hunk on hunk 0 applies all,
1545        // then next_hunk moves to hunk 1 with full preview.
1546        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1547        let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nLINE7\nLINE8";
1548        //         hunk 0: LINE2, LINE3          hunk 1: LINE7, LINE8
1549
1550        let engine = DiffEngine::new();
1551        let diff = engine.diff_strings(old, new);
1552        assert!(diff.hunks.len() >= 2, "Must have 2 hunks");
1553
1554        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1555
1556        // First next_hunk: apply all changes in hunk 0 (full preview)
1557        nav.next_hunk();
1558        assert_eq!(nav.state().current_hunk, 0);
1559        assert_eq!(
1560            nav.state().current_step,
1561            2,
1562            "Should apply all 2 changes in hunk 0"
1563        );
1564        assert!(nav.state().hunk_preview_mode);
1565
1566        // Second next_hunk: move to hunk 1 with full preview
1567        nav.next_hunk();
1568        assert_eq!(nav.state().current_hunk, 1, "Should be in hunk 1");
1569
1570        // All of hunk 0 (2 changes) + all of hunk 1 (2 changes) = 4 total
1571        assert_eq!(nav.state().current_step, 4, "Should have applied 4 changes");
1572        assert!(nav.state().hunk_preview_mode);
1573
1574        // Cursor should be on LINE7 (first change of hunk 1)
1575        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1576        let primary = view.iter().find(|l| l.is_primary_active);
1577        assert!(primary.is_some());
1578        assert!(
1579            primary.unwrap().content.contains("LINE7"),
1580            "Cursor should be on LINE7"
1581        );
1582    }
1583
1584    #[test]
1585    fn test_next_hunk_on_last_hunk_stays_at_end() {
1586        // Single hunk with 2 changes. Calling next_hunk applies all changes.
1587        // Calling next_hunk again returns false (no next hunk).
1588        let old = "line1\nline2\nline3";
1589        let new = "line1\nLINE2\nLINE3";
1590
1591        let engine = DiffEngine::new();
1592        let diff = engine.diff_strings(old, new);
1593        assert_eq!(diff.hunks.len(), 1, "Must have exactly 1 hunk");
1594        assert_eq!(
1595            diff.hunks[0].change_ids.len(),
1596            2,
1597            "Hunk must have 2 changes"
1598        );
1599
1600        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1601
1602        // First next_hunk: apply all changes (full preview)
1603        let moved1 = nav.next_hunk();
1604        assert!(moved1);
1605        assert_eq!(nav.state().current_step, 2, "Should apply all 2 changes");
1606        assert!(nav.state().hunk_preview_mode);
1607
1608        // Second next_hunk: no next hunk, returns false
1609        let moved2 = nav.next_hunk();
1610        assert!(!moved2, "Should return false when no next hunk");
1611        assert_eq!(nav.state().current_step, 2, "Should still be at step 2");
1612    }
1613
1614    #[test]
1615    fn test_markers_persist_within_hunk() {
1616        // Stepping within a hunk after next_hunk should preserve extent markers
1617        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1618        let new = "line1\nLINE2\nLINE3\nline4\nline5\nline6\nline7\nline8";
1619
1620        let engine = DiffEngine::new();
1621        let diff = engine.diff_strings(old, new);
1622
1623        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1624
1625        nav.next_hunk();
1626        assert!(
1627            nav.state().last_nav_was_hunk,
1628            "last_nav_was_hunk should be true after next_hunk"
1629        );
1630
1631        // Step within hunk (still in hunk 0)
1632        nav.next();
1633        assert!(
1634            nav.state().last_nav_was_hunk,
1635            "last_nav_was_hunk should persist within hunk"
1636        );
1637    }
1638
1639    #[test]
1640    fn test_markers_clear_on_hunk_exit() {
1641        // Stepping into a different hunk should clear extent markers
1642        let old = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8";
1643        let new = "line1\nLINE2\nline3\nline4\nline5\nline6\nLINE7\nline8";
1644        //         hunk 0: LINE2                  hunk 1: LINE7
1645
1646        let engine = DiffEngine::new();
1647        let diff = engine.diff_strings(old, new);
1648        assert!(diff.hunks.len() >= 2, "Must have 2 hunks");
1649
1650        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1651
1652        // Apply hunk 0 via next_hunk
1653        nav.next_hunk();
1654        assert!(nav.state().last_nav_was_hunk);
1655
1656        // Step into hunk 1 via next() - should clear markers
1657        nav.next();
1658        assert!(
1659            !nav.state().last_nav_was_hunk,
1660            "Markers should clear when stepping into different hunk"
1661        );
1662    }
1663
1664    #[test]
1665    fn test_active_change_flag_in_hunk_preview() {
1666        // Single hunk with 2 changes: hunk preview should mark all lines active,
1667        // but only the first change is the active change.
1668        let old = "line1\nline2\nline3\n";
1669        let new = "LINE1\nLINE2\nline3\n";
1670
1671        let engine = DiffEngine::new();
1672        let diff = engine.diff_strings(old, new);
1673        assert_eq!(diff.hunks.len(), 1, "Expected a single hunk");
1674        assert!(
1675            diff.hunks[0].change_ids.len() >= 2,
1676            "Expected 2 changes in the hunk"
1677        );
1678
1679        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1680        nav.next_hunk();
1681
1682        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1683        let active_changes: Vec<_> = view.iter().filter(|l| l.is_active_change).collect();
1684        let active_lines: Vec<_> = view.iter().filter(|l| l.is_active).collect();
1685
1686        assert_eq!(active_changes.len(), 1, "Only one active change expected");
1687        assert!(
1688            active_lines.len() >= 2,
1689            "All hunk lines should be active during preview"
1690        );
1691        assert!(
1692            active_changes[0].is_primary_active,
1693            "Active change should be primary"
1694        );
1695        assert!(
1696            active_changes[0].content.contains("LINE1"),
1697            "Active change should be the first change in the hunk"
1698        );
1699    }
1700
1701    #[test]
1702    fn test_word_level_phase_aware_mixed_change() {
1703        // Mixed change: has both old (foo, 4) and new (bar, 5) content
1704        // Should swap old/new at phase boundary
1705        let old = "const foo = 4";
1706        let new = "const bar = 5";
1707
1708        let engine = DiffEngine::new().with_word_level(true);
1709        let diff = engine.diff_strings(old, new);
1710        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1711
1712        // Apply the change (makes it active)
1713        nav.next();
1714        assert!(nav.state().active_change.is_some());
1715
1716        // FadeOut (forward): should show OLD content
1717        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1718        assert_eq!(view.len(), 1);
1719        assert_eq!(
1720            view[0].content, "const foo = 4",
1721            "FadeOut should show old content for mixed word-level change"
1722        );
1723
1724        // FadeIn (forward): should show NEW content
1725        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1726        assert_eq!(view.len(), 1);
1727        assert_eq!(
1728            view[0].content, "const bar = 5",
1729            "FadeIn should show new content for mixed word-level change"
1730        );
1731
1732        // Idle: should show applied (new) content
1733        let view = nav.current_view_with_frame(AnimationFrame::Idle);
1734        assert_eq!(view.len(), 1);
1735        assert_eq!(
1736            view[0].content, "const bar = 5",
1737            "Idle should show applied (new) content"
1738        );
1739    }
1740
1741    #[test]
1742    fn test_word_level_insert_only_visible_both_phases() {
1743        // Insert-only change: should be visible across both FadeOut and FadeIn
1744        let old = "hello";
1745        let new = "hello world";
1746
1747        let engine = DiffEngine::new().with_word_level(true);
1748        let diff = engine.diff_strings(old, new);
1749        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1750
1751        // Apply the change
1752        nav.next();
1753
1754        // FadeOut: insert-only should still be visible (not hidden)
1755        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1756        assert_eq!(view.len(), 1);
1757        assert!(
1758            view[0].content.contains("world"),
1759            "Insert-only change should be visible during FadeOut, got: {}",
1760            view[0].content
1761        );
1762
1763        // FadeIn: should also be visible
1764        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1765        assert_eq!(view.len(), 1);
1766        assert!(
1767            view[0].content.contains("world"),
1768            "Insert-only change should be visible during FadeIn, got: {}",
1769            view[0].content
1770        );
1771    }
1772
1773    #[test]
1774    fn test_word_level_delete_only_visible_both_phases() {
1775        // Delete-only change: should be visible across both FadeOut and FadeIn
1776        let old = "hello world";
1777        let new = "hello";
1778
1779        let engine = DiffEngine::new().with_word_level(true);
1780        let diff = engine.diff_strings(old, new);
1781        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1782
1783        // Apply the change
1784        nav.next();
1785
1786        // FadeOut: delete-only should still be visible (showing old content being deleted)
1787        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1788        assert_eq!(view.len(), 1);
1789        assert!(
1790            view[0].content.contains("world"),
1791            "Delete-only change should show deleted content during FadeOut, got: {}",
1792            view[0].content
1793        );
1794
1795        // FadeIn: should also show the deletion
1796        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1797        assert_eq!(view.len(), 1);
1798        assert!(
1799            view[0].content.contains("world"),
1800            "Delete-only change should show deleted content during FadeIn, got: {}",
1801            view[0].content
1802        );
1803    }
1804
1805    #[test]
1806    fn test_word_level_insert_only_idle_respects_applied_state() {
1807        // Insert-only change: Idle frame should snap to real applied state
1808        // (no "phantom" inserts when animations are disabled)
1809        let old = "hello";
1810        let new = "hello world";
1811
1812        let engine = DiffEngine::new().with_word_level(true);
1813        let diff = engine.diff_strings(old, new);
1814        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1815
1816        // Apply then unapply
1817        nav.next();
1818        nav.prev();
1819
1820        // Idle should NOT contain "world" (it's unapplied)
1821        let view = nav.current_view_with_frame(AnimationFrame::Idle);
1822        assert_eq!(view.len(), 1);
1823        assert!(
1824            !view[0].content.contains("world"),
1825            "Idle should not show unapplied insert, got: {}",
1826            view[0].content
1827        );
1828    }
1829
1830    #[test]
1831    fn test_word_level_phase_aware_backward_with_multiple_changes() {
1832        // To properly test backward animation, we need multiple changes
1833        // so stepping back doesn't land on step 0 (which clears animation state)
1834        let old = "aaa\nconst foo = 4\nccc\nddd\neee\nfff\nggg\nconst bar = 8\niii\njjj";
1835        let new = "aaa\nconst bbb = 5\nccc\nddd\neee\nfff\nggg\nconst qux = 9\niii\njjj";
1836        //              ^ change 1 (word-level)              ^ change 2 (word-level)
1837
1838        let engine = DiffEngine::new().with_word_level(true);
1839        let diff = engine.diff_strings(old, new);
1840
1841        // Verify we have 2 changes
1842        assert!(
1843            diff.significant_changes.len() >= 2,
1844            "Expected at least 2 changes, got {}",
1845            diff.significant_changes.len()
1846        );
1847
1848        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1849
1850        // Apply both changes
1851        nav.next(); // step 1: first change applied
1852        nav.next(); // step 2: second change applied
1853        assert_eq!(nav.state().current_step, 2);
1854
1855        // Step back from step 2 to step 1 (second change is now active for backward animation)
1856        nav.prev();
1857        assert_eq!(nav.state().current_step, 1);
1858        assert_eq!(nav.state().step_direction, StepDirection::Backward);
1859        assert!(nav.state().active_change.is_some());
1860
1861        // Find the line that's active (should be line 8, word-level change being un-applied)
1862        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1863        let active_line = view.iter().find(|l| l.is_active);
1864        assert!(active_line.is_some(), "Should have an active line");
1865
1866        // Backward + FadeOut: should show NEW content (the content being removed)
1867        // For word-level, this means "const qux = 9"
1868        let active = active_line.unwrap();
1869        assert_eq!(
1870            active.content, "const qux = 9",
1871            "Backward FadeOut should show new content (being removed)"
1872        );
1873
1874        // Backward + FadeIn: should show OLD content (the content being restored)
1875        // For word-level, this means "const bar = 8"
1876        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1877        let active_line = view.iter().find(|l| l.is_active);
1878        assert!(active_line.is_some(), "Should have an active line");
1879        let active = active_line.unwrap();
1880        assert_eq!(
1881            active.content, "const bar = 8",
1882            "Backward FadeIn should show old content (being restored)"
1883        );
1884    }
1885
1886    #[test]
1887    fn test_word_level_insert_only_backward_visible_both_phases() {
1888        // Insert-only word-level change should stay visible during backward animation
1889        // Test: "hello" -> "hello world" is insert-only (only adds " world")
1890        // We need 2 changes to avoid landing on step 0
1891        let old = "aaa\nhello\nccc\nddd\neee\nfff\nggg\nfoo\niii\njjj";
1892        let new = "aaa\nhello world\nccc\nddd\neee\nfff\nggg\nfoo bar\niii\njjj";
1893        //              ^ insert-only (add " world")       ^ insert-only (add " bar")
1894
1895        let engine = DiffEngine::new().with_word_level(true);
1896        let diff = engine.diff_strings(old, new);
1897
1898        assert!(
1899            diff.significant_changes.len() >= 2,
1900            "Need 2+ changes to avoid landing on step 0, got {}",
1901            diff.significant_changes.len()
1902        );
1903
1904        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1905
1906        // Apply both changes, then step back
1907        nav.next();
1908        nav.next();
1909        nav.prev();
1910
1911        assert_eq!(nav.state().step_direction, StepDirection::Backward);
1912        assert!(nav.state().active_change.is_some());
1913
1914        // FadeOut: insert-only should still be visible (shows the inserted content)
1915        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1916        let active_line = view.iter().find(|l| l.is_active);
1917        assert!(
1918            active_line.is_some(),
1919            "Should have an active line during FadeOut"
1920        );
1921        let active = active_line.unwrap();
1922        assert!(
1923            active.content.contains("bar"),
1924            "Backward FadeOut should show insert-only content, got: {}",
1925            active.content
1926        );
1927
1928        // FadeIn: should also show the inserted content (visible both phases)
1929        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1930        let active_line = view.iter().find(|l| l.is_active);
1931        assert!(
1932            active_line.is_some(),
1933            "Should have an active line during FadeIn"
1934        );
1935        let active = active_line.unwrap();
1936        assert!(
1937            active.content.contains("bar"),
1938            "Backward FadeIn should show insert-only content, got: {}",
1939            active.content
1940        );
1941    }
1942
1943    #[test]
1944    fn test_word_level_active_line_kind_pending_modify() {
1945        // Active word-level lines should have LineKind::PendingModify
1946        // (evolution view filters on LineKind::PendingDelete, so this prevents drops)
1947        let old = "const foo = 4";
1948        let new = "const bar = 5";
1949
1950        let engine = DiffEngine::new().with_word_level(true);
1951        let diff = engine.diff_strings(old, new);
1952        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1953
1954        nav.next(); // active change
1955
1956        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
1957        assert_eq!(view.len(), 1);
1958        assert_eq!(
1959            view[0].kind,
1960            LineKind::PendingModify,
1961            "Active word-level line should have PendingModify kind during FadeOut"
1962        );
1963
1964        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1965        assert_eq!(view.len(), 1);
1966        assert_eq!(
1967            view[0].kind,
1968            LineKind::PendingModify,
1969            "Active word-level line should have PendingModify kind during FadeIn"
1970        );
1971    }
1972
1973    #[test]
1974    fn test_primary_active_unique_when_active_change_set() {
1975        // When active_change is set, exactly one line should be is_primary_active
1976        // and that line must also be is_active
1977        let old = "a\nb\nc\n";
1978        let new = "a\nB\nc\n";
1979        let diff = DiffEngine::new().diff_strings(old, new);
1980        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
1981
1982        nav.next();
1983        let view = nav.current_view_with_frame(AnimationFrame::FadeIn);
1984
1985        let primary: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
1986        assert_eq!(
1987            primary.len(),
1988            1,
1989            "Exactly one line should be primary active"
1990        );
1991        assert!(
1992            primary[0].is_active,
1993            "Primary active line must also be is_active"
1994        );
1995    }
1996
1997    #[test]
1998    fn test_hunk_extent_not_primary() {
1999        // Multi-line hunk: all lines should be active during animation,
2000        // but only one should be is_primary_active (for gutter marker)
2001        let old = "a\nb\nc\nd\n";
2002        let new = "A\nb\nC\nd\n"; // A and C form one hunk (b is unchanged but within proximity)
2003        let diff = DiffEngine::new().diff_strings(old, new);
2004
2005        assert_eq!(
2006            diff.hunks.len(),
2007            1,
2008            "Fixture should produce a single hunk; adjust the unchanged gap if this fails"
2009        );
2010
2011        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
2012
2013        nav.next_hunk();
2014        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2015
2016        // During animation, whole hunk lights up as active
2017        let active = view.iter().filter(|l| l.is_active).count();
2018        let extent = view.iter().filter(|l| l.show_hunk_extent).count();
2019        let primary = view.iter().filter(|l| l.is_primary_active).count();
2020
2021        assert!(
2022            active > 1,
2023            "Multiple lines should be active during animation, got {}",
2024            active
2025        );
2026        assert!(
2027            extent > 1,
2028            "Multiple lines should show extent markers, got {}",
2029            extent
2030        );
2031        assert_eq!(primary, 1, "Exactly one line should be primary active");
2032    }
2033
2034    #[test]
2035    fn test_hunk_extent_while_stepping() {
2036        // When stepping (not hunk-nav), extent markers should still show
2037        // if explicitly enabled by the UI.
2038        let old = "one\ntwo\nthree\nfour\n";
2039        let new = "ONE\nTWO\nthree\nfour\n";
2040        let diff = DiffEngine::new().diff_strings(old, new);
2041        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
2042
2043        nav.next();
2044        nav.set_show_hunk_extent_while_stepping(true);
2045        let view = nav.current_view_with_frame(AnimationFrame::Idle);
2046
2047        let extent = view.iter().filter(|l| l.show_hunk_extent).count();
2048        assert!(
2049            extent > 0,
2050            "Extent markers should show while stepping when enabled"
2051        );
2052    }
2053
2054    #[test]
2055    fn test_primary_active_fallback_when_active_change_none() {
2056        // When active_change is None but animating_hunk is set,
2057        // the first line in the hunk should become primary and be active
2058        let old = "a\nb\nc\n";
2059        let new = "A\nb\nC\n"; // two changes in same hunk
2060        let diff = DiffEngine::new().diff_strings(old, new);
2061        let mut nav = DiffNavigator::new(diff, old.to_string(), new.to_string());
2062
2063        // Force animating hunk without active_change
2064        nav.state_mut().animating_hunk = Some(0);
2065        nav.state_mut().active_change = None;
2066        nav.state_mut().step_direction = StepDirection::Forward;
2067
2068        let view = nav.current_view_with_frame(AnimationFrame::FadeOut);
2069
2070        let primary: Vec<_> = view.iter().filter(|l| l.is_primary_active).collect();
2071        assert_eq!(
2072            primary.len(),
2073            1,
2074            "Exactly one line should be primary active"
2075        );
2076        assert!(
2077            primary[0].is_active,
2078            "Primary active line must also be is_active"
2079        );
2080
2081        // Verify it's the first active line in the view
2082        let first_active_idx = view.iter().position(|l| l.is_active).unwrap();
2083        let first_primary_idx = view.iter().position(|l| l.is_primary_active).unwrap();
2084        assert_eq!(
2085            first_active_idx, first_primary_idx,
2086            "First active line should be the primary line"
2087        );
2088    }
2089}