#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy //! Browser-like navigation stack with vim-style jump list. //! //! Supports back/forward (browser), jump back/forward (vim Ctrl+O/Ctrl+I), //! and breadcrumb generation. State is preserved when navigating away — //! screens are never cleared on pop. use crate::message::Screen; // --------------------------------------------------------------------------- // NavigationStack // --------------------------------------------------------------------------- /// Browser-like navigation with back/forward stacks and a vim jump list. /// /// The jump list only records "significant" hops — detail views and /// cross-references — skipping list/dashboard screens that users /// visit briefly during drilling. pub struct NavigationStack { back_stack: Vec, current: Screen, forward_stack: Vec, jump_list: Vec, jump_index: usize, } impl NavigationStack { /// Create a new stack starting at the Dashboard. #[must_use] pub fn new() -> Self { Self { back_stack: Vec::new(), current: Screen::Dashboard, forward_stack: Vec::new(), jump_list: Vec::new(), jump_index: 0, } } /// The currently displayed screen. #[must_use] pub fn current(&self) -> &Screen { &self.current } /// Whether the current screen matches the given screen. #[must_use] pub fn is_at(&self, screen: &Screen) -> bool { &self.current == screen } /// Navigate to a new screen. /// /// Pushes current to back_stack, clears forward_stack (browser behavior), /// and records detail hops in the jump list. pub fn push(&mut self, screen: Screen) { let old = std::mem::replace(&mut self.current, screen); self.back_stack.push(old); self.forward_stack.clear(); // Record significant hops in jump list (vim behavior): // Keep entries up to and including the current position, discard // any forward entries beyond it, then append the new destination. if self.current.is_detail_or_entity() { self.jump_list.truncate(self.jump_index.saturating_add(1)); self.jump_list.push(self.current.clone()); self.jump_index = self.jump_list.len(); } } /// Go back to the previous screen. /// /// Returns `None` at root (can't pop past the initial screen). pub fn pop(&mut self) -> Option<&Screen> { let prev = self.back_stack.pop()?; let old = std::mem::replace(&mut self.current, prev); self.forward_stack.push(old); Some(&self.current) } /// Go forward (redo a pop). /// /// Returns `None` if there's nothing to go forward to. pub fn go_forward(&mut self) -> Option<&Screen> { let next = self.forward_stack.pop()?; let old = std::mem::replace(&mut self.current, next); self.back_stack.push(old); Some(&self.current) } /// Jump backward through the jump list (vim Ctrl+O). /// /// Only visits detail/entity screens. Skips entries matching the /// current screen so the first press always produces a visible change. pub fn jump_back(&mut self) -> Option<&Screen> { while self.jump_index > 0 { self.jump_index -= 1; if let Some(target) = self.jump_list.get(self.jump_index).cloned() && target != self.current { self.current = target; return Some(&self.current); } } None } /// Jump forward through the jump list (vim Ctrl+I). /// /// Skips entries matching the current screen. pub fn jump_forward(&mut self) -> Option<&Screen> { while self.jump_index < self.jump_list.len() { if let Some(target) = self.jump_list.get(self.jump_index).cloned() { self.jump_index += 1; if target != self.current { self.current = target; return Some(&self.current); } } else { break; } } None } /// Reset to a single screen, clearing all history. pub fn reset_to(&mut self, screen: Screen) { self.current = screen; self.back_stack.clear(); self.forward_stack.clear(); self.jump_list.clear(); self.jump_index = 0; } /// Breadcrumb labels for the current navigation path. /// /// Returns the back stack labels plus the current screen label. #[must_use] pub fn breadcrumbs(&self) -> Vec<&str> { self.back_stack .iter() .chain(std::iter::once(&self.current)) .map(Screen::label) .collect() } /// Navigation depth (1 = at root, 2 = one push deep, etc.). #[must_use] pub fn depth(&self) -> usize { self.back_stack.len() + 1 } /// Whether there's anything to go back to. #[must_use] pub fn can_go_back(&self) -> bool { !self.back_stack.is_empty() } /// Whether there's anything to go forward to. #[must_use] pub fn can_go_forward(&self) -> bool { !self.forward_stack.is_empty() } } impl Default for NavigationStack { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::message::EntityKey; #[test] fn test_new_starts_at_dashboard() { let nav = NavigationStack::new(); assert!(nav.is_at(&Screen::Dashboard)); assert_eq!(nav.depth(), 1); } #[test] fn test_push_pop_preserves_order() { let mut nav = NavigationStack::new(); nav.push(Screen::IssueList); nav.push(Screen::IssueDetail(EntityKey::issue(1, 42))); assert!(nav.is_at(&Screen::IssueDetail(EntityKey::issue(1, 42)))); assert_eq!(nav.depth(), 3); nav.pop(); assert!(nav.is_at(&Screen::IssueList)); nav.pop(); assert!(nav.is_at(&Screen::Dashboard)); } #[test] fn test_pop_at_root_returns_none() { let mut nav = NavigationStack::new(); assert!(nav.pop().is_none()); assert!(nav.is_at(&Screen::Dashboard)); } #[test] fn test_forward_stack_cleared_on_new_push() { let mut nav = NavigationStack::new(); nav.push(Screen::IssueList); nav.push(Screen::Search); nav.pop(); // back to IssueList, Search in forward assert!(nav.can_go_forward()); nav.push(Screen::Timeline); // new push clears forward assert!(!nav.can_go_forward()); } #[test] fn test_go_forward_restores() { let mut nav = NavigationStack::new(); nav.push(Screen::IssueList); nav.push(Screen::Search); nav.pop(); // back to IssueList let screen = nav.go_forward(); assert!(screen.is_some()); assert!(nav.is_at(&Screen::Search)); } #[test] fn test_go_forward_returns_none_when_empty() { let mut nav = NavigationStack::new(); assert!(nav.go_forward().is_none()); } #[test] fn test_jump_list_skips_list_screens() { let mut nav = NavigationStack::new(); nav.push(Screen::IssueList); // not a detail — skip nav.push(Screen::IssueDetail(EntityKey::issue(1, 1))); // detail — record nav.push(Screen::MrList); // not a detail — skip nav.push(Screen::MrDetail(EntityKey::mr(1, 2))); // detail — record assert_eq!(nav.jump_list.len(), 2); } #[test] fn test_jump_back_and_forward() { let mut nav = NavigationStack::new(); let issue = Screen::IssueDetail(EntityKey::issue(1, 1)); let mr = Screen::MrDetail(EntityKey::mr(1, 2)); nav.push(Screen::IssueList); nav.push(issue.clone()); nav.push(Screen::MrList); nav.push(mr.clone()); // Current is MrDetail. jump_list = [IssueDetail, MrDetail], index = 2. // First jump_back skips MrDetail (== current) and lands on IssueDetail. let prev = nav.jump_back(); assert_eq!(prev, Some(&issue)); assert!(nav.is_at(&issue)); // Already at beginning of jump list. assert!(nav.jump_back().is_none()); // jump_forward skips IssueDetail (== current) and lands on MrDetail. let next = nav.jump_forward(); assert_eq!(next, Some(&mr)); assert!(nav.is_at(&mr)); // At end of jump list. assert!(nav.jump_forward().is_none()); } #[test] fn test_jump_list_truncates_on_new_push() { let mut nav = NavigationStack::new(); nav.push(Screen::IssueDetail(EntityKey::issue(1, 1))); nav.push(Screen::IssueDetail(EntityKey::issue(1, 2))); nav.push(Screen::IssueDetail(EntityKey::issue(1, 3))); // jump back twice — lands on issue(1,1), jump_index = 0 nav.jump_back(); nav.jump_back(); // new detail push truncates forward entries nav.push(Screen::MrDetail(EntityKey::mr(1, 99))); // should have issue(1,1) and mr(1,99), not issue(1,2) or issue(1,3) assert_eq!(nav.jump_list.len(), 2); assert_eq!(nav.jump_list[1], Screen::MrDetail(EntityKey::mr(1, 99))); } #[test] fn test_reset_clears_all_history() { let mut nav = NavigationStack::new(); nav.push(Screen::IssueList); nav.push(Screen::Search); nav.push(Screen::IssueDetail(EntityKey::issue(1, 1))); nav.reset_to(Screen::Dashboard); assert!(nav.is_at(&Screen::Dashboard)); assert_eq!(nav.depth(), 1); assert!(!nav.can_go_back()); assert!(!nav.can_go_forward()); assert!(nav.jump_list.is_empty()); } #[test] fn test_breadcrumbs_reflect_stack() { let mut nav = NavigationStack::new(); assert_eq!(nav.breadcrumbs(), vec!["Dashboard"]); nav.push(Screen::IssueList); assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues"]); nav.push(Screen::IssueDetail(EntityKey::issue(1, 42))); assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues", "Issue"]); } #[test] fn test_default_is_new() { let nav = NavigationStack::default(); assert!(nav.is_at(&Screen::Dashboard)); assert_eq!(nav.depth(), 1); } #[test] fn test_can_go_back_and_forward() { let mut nav = NavigationStack::new(); assert!(!nav.can_go_back()); assert!(!nav.can_go_forward()); nav.push(Screen::IssueList); assert!(nav.can_go_back()); assert!(!nav.can_go_forward()); nav.pop(); assert!(!nav.can_go_back()); assert!(nav.can_go_forward()); } }