use std::collections::HashMap;
use ratatui::widgets::ListState;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Tab {
Overview,
CoverageMatrix,
ValidationGates,
PolicyViolations,
AuditLog,
Dependencies,
CatalogBrowser,
Settings,
}
impl Tab {
pub const ALL: &'static [Tab] = &[
Tab::Overview,
Tab::CoverageMatrix,
Tab::ValidationGates,
Tab::PolicyViolations,
Tab::AuditLog,
Tab::Dependencies,
Tab::CatalogBrowser,
Tab::Settings,
];
pub const COUNT: usize = Self::ALL.len();
pub fn index(&self) -> usize {
Self::ALL
.iter()
.position(|t| t == self)
.expect("every Tab variant must appear in Tab::ALL")
}
pub fn label(&self) -> &'static str {
match self {
Tab::Overview => "Overview",
Tab::CoverageMatrix => "Coverage",
Tab::ValidationGates => "Gates",
Tab::PolicyViolations => "Violations",
Tab::AuditLog => "Audit Log",
Tab::Dependencies => "Deps",
Tab::CatalogBrowser => "Catalog",
Tab::Settings => "Settings",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum DrillView {
WorkspaceDetail(String),
GateDAG,
GateOutput(String),
ViolationDetail(usize),
AgentDetail(String),
SkillDetail(String),
McpDetail(String),
RoleDetail(String),
RuleDetail(String),
IntegrityDetail(String),
DependencyFocus(String),
HelpOverlay,
SearchOverlay,
}
#[derive(Debug, Clone, PartialEq)]
pub enum View {
AgentList,
AgentDetail(String),
SkillList,
SkillDetail(String),
RoleList,
RoleDetail(String),
ProviderList,
ProviderAgents(String),
McpList,
McpDetail(String),
RuleList,
RuleDetail(String),
ValidationList,
ValidationOutput(String),
ExportBuilder,
ExportConfirm,
ExportOutput,
IntegrityOverview,
IntegrityDetail(String),
}
pub type SidebarEntry = (&'static str, fn() -> View);
pub const SIDEBAR_SECTIONS: &[SidebarEntry] = &[
("Agents", || View::AgentList),
("Skills", || View::SkillList),
("Roles", || View::RoleList),
("Providers", || View::ProviderList),
("MCP References", || View::McpList),
("Rules", || View::RuleList),
("Validation", || View::ValidationList),
("Export", || View::ExportBuilder),
("Integrity", || View::IntegrityOverview),
];
#[derive(Debug, Clone, PartialEq)]
pub enum NavAction {
Down,
Up,
First,
Last,
PageDown,
PageUp,
NextTab,
PrevTab,
Enter,
Back,
ActivateSearch,
ShowHelp,
Unhandled,
}
pub struct NavigationState {
pub current_view: View,
pub history: Vec<View>,
pub sidebar_index: usize,
pub list_state: ListState,
pub detail_scroll: u16,
pub current_tab: Tab,
pub tab_history: Vec<DrillView>,
pub list_states: HashMap<Tab, ListState>,
pub search_active: bool,
pub search_query: String,
}
impl NavigationState {
pub fn new() -> Self {
let mut list_state = ListState::default();
list_state.select(Some(0));
let mut list_states: HashMap<Tab, ListState> = HashMap::new();
for tab in Tab::ALL {
let mut ls = ListState::default();
ls.select(Some(0));
list_states.insert(tab.clone(), ls);
}
Self {
current_view: View::AgentList,
history: Vec::new(),
sidebar_index: 0,
list_state,
detail_scroll: 0,
current_tab: Tab::Overview,
tab_history: Vec::new(),
list_states,
search_active: false,
search_query: String::new(),
}
}
pub const MAX_HISTORY: usize = 64;
pub const MAX_TAB_HISTORY: usize = 20;
pub fn push_view(&mut self, view: View) {
self.history.push(self.current_view.clone());
if self.history.len() > Self::MAX_HISTORY {
self.history.remove(0);
}
self.current_view = view;
self.list_state.select(Some(0));
self.detail_scroll = 0;
}
pub fn pop_view(&mut self) -> bool {
if let Some(prev) = self.history.pop() {
self.current_view = prev;
self.list_state.select(Some(0));
self.detail_scroll = 0;
true
} else {
false
}
}
pub fn set_sidebar_index(&mut self, idx: usize) {
if idx < SIDEBAR_SECTIONS.len() {
self.sidebar_index = idx;
let view_fn = SIDEBAR_SECTIONS[idx].1;
let new_view = view_fn();
self.history.clear();
self.current_view = new_view;
self.list_state.select(Some(0));
self.detail_scroll = 0;
}
}
pub fn select_next(&mut self, max: usize) {
if max == 0 {
return;
}
let current = self.selected_index();
if current < max.saturating_sub(1) {
self.list_state.select(Some(current + 1));
}
}
pub fn select_prev(&mut self) {
let current = self.selected_index();
if current > 0 {
self.list_state.select(Some(current - 1));
}
}
pub fn select_first(&mut self) {
self.list_state.select(Some(0));
}
pub fn select_last(&mut self, max: usize) {
if max > 0 {
self.list_state.select(Some(max - 1));
}
}
pub fn selected_index(&self) -> usize {
self.list_state.selected().unwrap_or(0)
}
pub fn next_tab(&mut self) {
let idx = self.current_tab.index();
let next_idx = (idx + 1) % Tab::COUNT;
self.current_tab = Tab::ALL[next_idx].clone();
}
pub fn prev_tab(&mut self) {
let idx = self.current_tab.index();
let prev_idx = if idx == 0 { Tab::COUNT - 1 } else { idx - 1 };
self.current_tab = Tab::ALL[prev_idx].clone();
}
pub fn push_drill(&mut self, view: DrillView) {
if self.tab_history.len() >= Self::MAX_TAB_HISTORY {
self.tab_history.remove(0);
}
self.tab_history.push(view);
}
pub fn pop_drill(&mut self) -> Option<DrillView> {
self.tab_history.pop()
}
pub fn current_drill(&self) -> Option<&DrillView> {
self.tab_history.last()
}
pub fn activate_search(&mut self) {
self.search_active = true;
self.search_query.clear();
}
pub fn deactivate_search(&mut self) {
self.search_active = false;
}
pub fn tab_list_state_mut(&mut self, tab: &Tab) -> &mut ListState {
self.list_states.entry(tab.clone()).or_insert_with(|| {
let mut ls = ListState::default();
ls.select(Some(0));
ls
})
}
pub fn tab_list_state(&self, tab: &Tab) -> Option<&ListState> {
self.list_states.get(tab)
}
}
impl Default for NavigationState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_starts_at_agent_list() {
let nav = NavigationState::new();
assert_eq!(nav.current_view, View::AgentList);
assert!(nav.history.is_empty());
assert_eq!(nav.sidebar_index, 0);
assert_eq!(nav.selected_index(), 0);
}
#[test]
fn push_view_adds_to_history() {
let mut nav = NavigationState::new();
nav.push_view(View::AgentDetail("test-agent".to_string()));
assert_eq!(
nav.current_view,
View::AgentDetail("test-agent".to_string())
);
assert_eq!(nav.history.len(), 1);
assert_eq!(nav.history[0], View::AgentList);
}
#[test]
fn pop_view_returns_to_previous() {
let mut nav = NavigationState::new();
nav.push_view(View::AgentDetail("test".to_string()));
assert!(nav.pop_view());
assert_eq!(nav.current_view, View::AgentList);
assert!(nav.history.is_empty());
}
#[test]
fn pop_view_at_root_returns_false() {
let mut nav = NavigationState::new();
assert!(!nav.pop_view());
assert_eq!(nav.current_view, View::AgentList);
}
#[test]
fn set_sidebar_index_changes_view() {
let mut nav = NavigationState::new();
nav.set_sidebar_index(1);
assert_eq!(nav.sidebar_index, 1);
assert_eq!(nav.current_view, View::SkillList);
assert!(nav.history.is_empty());
}
#[test]
fn set_sidebar_index_clears_history() {
let mut nav = NavigationState::new();
nav.push_view(View::AgentDetail("x".to_string()));
nav.set_sidebar_index(2);
assert!(nav.history.is_empty());
assert_eq!(nav.current_view, View::RoleList);
}
#[test]
fn select_next_stops_at_boundary() {
let mut nav = NavigationState::new();
nav.select_next(3);
assert_eq!(nav.selected_index(), 1);
nav.select_next(3);
assert_eq!(nav.selected_index(), 2);
nav.select_next(3);
assert_eq!(nav.selected_index(), 2); }
#[test]
fn select_prev_stops_at_zero() {
let mut nav = NavigationState::new();
nav.select_prev();
assert_eq!(nav.selected_index(), 0); }
#[test]
fn select_first_and_last() {
let mut nav = NavigationState::new();
nav.select_next(10);
nav.select_next(10);
nav.select_next(10);
nav.select_last(10);
assert_eq!(nav.selected_index(), 9);
nav.select_first();
assert_eq!(nav.selected_index(), 0);
}
#[test]
fn select_next_empty_list_is_safe() {
let mut nav = NavigationState::new();
nav.select_next(0);
assert_eq!(nav.selected_index(), 0);
}
#[test]
fn select_last_empty_list_is_safe() {
let mut nav = NavigationState::new();
nav.select_last(0);
assert_eq!(nav.selected_index(), 0);
}
#[test]
fn sidebar_sections_count() {
assert_eq!(SIDEBAR_SECTIONS.len(), 9);
}
#[test]
fn all_sidebar_views_are_correct() {
let expected_views = vec![
View::AgentList,
View::SkillList,
View::RoleList,
View::ProviderList,
View::McpList,
View::RuleList,
View::ValidationList,
View::ExportBuilder,
View::IntegrityOverview,
];
for (i, expected) in expected_views.iter().enumerate() {
let view_fn = SIDEBAR_SECTIONS[i].1;
assert_eq!(&view_fn(), expected);
}
}
#[test]
fn push_view_resets_selection() {
let mut nav = NavigationState::new();
nav.select_next(10);
nav.select_next(10);
assert_eq!(nav.selected_index(), 2);
nav.push_view(View::SkillList);
assert_eq!(nav.selected_index(), 0);
}
#[test]
fn push_view_caps_history_at_64() {
let mut nav = NavigationState::new();
for i in 0..100 {
nav.push_view(View::AgentDetail(format!("agent-{i}")));
}
assert!(nav.history.len() <= NavigationState::MAX_HISTORY);
assert_eq!(nav.history.len(), NavigationState::MAX_HISTORY);
}
#[test]
fn tab_all_has_eight_variants() {
assert_eq!(Tab::ALL.len(), 8);
assert_eq!(Tab::COUNT, 8);
}
#[test]
fn tab_index_round_trips() {
for (expected_idx, tab) in Tab::ALL.iter().enumerate() {
assert_eq!(tab.index(), expected_idx);
}
}
#[test]
fn tab_labels_are_non_empty() {
for tab in Tab::ALL {
assert!(
!tab.label().is_empty(),
"label for {tab:?} must not be empty"
);
}
}
#[test]
fn next_tab_wraps_from_settings_to_overview() {
let mut nav = NavigationState::new();
nav.current_tab = Tab::Settings;
nav.next_tab();
assert_eq!(nav.current_tab, Tab::Overview);
}
#[test]
fn prev_tab_wraps_from_overview_to_settings() {
let mut nav = NavigationState::new();
nav.current_tab = Tab::Overview;
nav.prev_tab();
assert_eq!(nav.current_tab, Tab::Settings);
}
#[test]
fn next_tab_cycles_full_sequence() {
let mut nav = NavigationState::new();
nav.current_tab = Tab::Overview;
let expected = [
Tab::CoverageMatrix,
Tab::ValidationGates,
Tab::PolicyViolations,
Tab::AuditLog,
Tab::Dependencies,
Tab::CatalogBrowser,
Tab::Settings,
Tab::Overview, ];
for exp in &expected {
nav.next_tab();
assert_eq!(&nav.current_tab, exp);
}
}
#[test]
fn prev_tab_cycles_reverse_sequence() {
let mut nav = NavigationState::new();
nav.current_tab = Tab::Overview;
let expected = [
Tab::Settings,
Tab::CatalogBrowser,
Tab::Dependencies,
Tab::AuditLog,
Tab::PolicyViolations,
Tab::ValidationGates,
Tab::CoverageMatrix,
Tab::Overview, ];
for exp in &expected {
nav.prev_tab();
assert_eq!(&nav.current_tab, exp);
}
}
#[test]
fn tab_cycling_next_then_prev_is_identity() {
for start_tab in Tab::ALL {
let mut nav = NavigationState::new();
nav.current_tab = start_tab.clone();
let n = Tab::COUNT * 3; for _ in 0..n {
nav.next_tab();
}
for _ in 0..n {
nav.prev_tab();
}
assert_eq!(
nav.current_tab, *start_tab,
"after {n} next + {n} prev, should return to {start_tab:?}"
);
}
}
#[test]
fn tab_cycling_full_cycle_returns_to_start() {
for start_tab in Tab::ALL {
let mut nav = NavigationState::new();
nav.current_tab = start_tab.clone();
for _ in 0..Tab::COUNT {
nav.next_tab();
}
assert_eq!(
nav.current_tab, *start_tab,
"full forward cycle should return to {start_tab:?}"
);
}
}
#[test]
fn tab_cycling_full_reverse_cycle_returns_to_start() {
for start_tab in Tab::ALL {
let mut nav = NavigationState::new();
nav.current_tab = start_tab.clone();
for _ in 0..Tab::COUNT {
nav.prev_tab();
}
assert_eq!(
nav.current_tab, *start_tab,
"full reverse cycle should return to {start_tab:?}"
);
}
}
#[test]
fn tab_cycling_property_256_cases() {
let step_counts: Vec<usize> = (1..=32).collect(); let mut case_count = 0usize;
for start_tab in Tab::ALL {
for &steps in &step_counts {
let mut nav = NavigationState::new();
nav.current_tab = start_tab.clone();
for _ in 0..steps {
nav.next_tab();
}
for _ in 0..steps {
nav.prev_tab();
}
assert_eq!(
nav.current_tab, *start_tab,
"case (start={start_tab:?}, steps={steps}): expected round-trip to start"
);
case_count += 1;
}
}
assert_eq!(case_count, 256, "must exercise exactly 256 cases");
}
#[test]
fn push_drill_adds_to_tab_history() {
let mut nav = NavigationState::new();
nav.push_drill(DrillView::AgentDetail("agent-1".to_string()));
assert_eq!(nav.tab_history.len(), 1);
assert_eq!(
nav.current_drill(),
Some(&DrillView::AgentDetail("agent-1".to_string()))
);
}
#[test]
fn pop_drill_removes_from_tab_history() {
let mut nav = NavigationState::new();
nav.push_drill(DrillView::AgentDetail("agent-1".to_string()));
nav.push_drill(DrillView::SkillDetail("skill-1".to_string()));
let popped = nav.pop_drill();
assert_eq!(popped, Some(DrillView::SkillDetail("skill-1".to_string())));
assert_eq!(nav.tab_history.len(), 1);
let popped2 = nav.pop_drill();
assert_eq!(popped2, Some(DrillView::AgentDetail("agent-1".to_string())));
assert!(nav.tab_history.is_empty());
}
#[test]
fn pop_drill_at_root_returns_none() {
let mut nav = NavigationState::new();
assert_eq!(nav.pop_drill(), None);
}
#[test]
fn push_drill_respects_max_20_depth() {
let mut nav = NavigationState::new();
for i in 0..30 {
nav.push_drill(DrillView::AgentDetail(format!("agent-{i}")));
}
assert_eq!(
nav.tab_history.len(),
NavigationState::MAX_TAB_HISTORY,
"tab_history must be capped at MAX_TAB_HISTORY ({})",
NavigationState::MAX_TAB_HISTORY
);
assert_eq!(
nav.tab_history[0],
DrillView::AgentDetail("agent-10".to_string()),
"after 30 pushes the oldest retained entry should be agent-10"
);
assert_eq!(
nav.tab_history.last(),
Some(&DrillView::AgentDetail("agent-29".to_string()))
);
}
#[test]
fn activate_search_sets_flag_and_clears_query() {
let mut nav = NavigationState::new();
nav.search_query = "previous query".to_string();
nav.activate_search();
assert!(nav.search_active);
assert!(nav.search_query.is_empty());
}
#[test]
fn deactivate_search_clears_flag_preserves_query() {
let mut nav = NavigationState::new();
nav.activate_search();
nav.search_query = "aws".to_string();
nav.deactivate_search();
assert!(!nav.search_active);
assert_eq!(nav.search_query, "aws"); }
#[test]
fn activate_deactivate_toggles_correctly() {
let mut nav = NavigationState::new();
assert!(!nav.search_active);
nav.activate_search();
assert!(nav.search_active);
nav.deactivate_search();
assert!(!nav.search_active);
}
#[test]
fn per_tab_list_states_initialised_for_all_tabs() {
let nav = NavigationState::new();
for tab in Tab::ALL {
assert!(
nav.tab_list_state(tab).is_some(),
"list_state must be initialised for {tab:?}"
);
}
}
#[test]
fn per_tab_scroll_positions_preserved_across_tab_switches() {
let mut nav = NavigationState::new();
{
let ls = nav.tab_list_state_mut(&Tab::CoverageMatrix);
ls.select(Some(5));
}
nav.current_tab = Tab::CatalogBrowser;
{
let ls = nav.tab_list_state_mut(&Tab::CatalogBrowser);
ls.select(Some(3));
}
nav.current_tab = Tab::CoverageMatrix;
let ls = nav.tab_list_state(&Tab::CoverageMatrix).unwrap();
assert_eq!(
ls.selected(),
Some(5),
"CoverageMatrix scroll position should be preserved after switching tabs"
);
let ls2 = nav.tab_list_state(&Tab::CatalogBrowser).unwrap();
assert_eq!(
ls2.selected(),
Some(3),
"CatalogBrowser scroll position should be preserved"
);
}
#[test]
fn tab_list_state_mut_creates_entry_if_missing() {
let mut nav = NavigationState::new();
nav.list_states.remove(&Tab::AuditLog);
let ls = nav.tab_list_state_mut(&Tab::AuditLog);
assert_eq!(ls.selected(), Some(0));
}
}