Implement state, action, and view layers for both list screens: - Issue List: keyset pagination, snapshot fence, filter DSL, label aggregation - MR List: mirrors Issue pattern with draft/reviewer/target branch filters - Migration 027: covering indexes for TUI list screen queries - Updated Msg types to use typed Page structs instead of raw Vec<Row> - 303 tests passing, clippy clean Beads: bd-3ei1, bd-2kr0, bd-3pm2
351 lines
11 KiB
Rust
351 lines
11 KiB
Rust
#![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<Screen>,
|
|
current: Screen,
|
|
forward_stack: Vec<Screen>,
|
|
jump_list: Vec<Screen>,
|
|
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());
|
|
}
|
|
}
|