#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy //! Top-level state composition for the TUI. //! //! Each screen has its own state struct. State is preserved when //! navigating away — screens are never cleared on pop. //! //! [`LoadState`] enables stale-while-revalidate: screens show the last //! available data during a refresh, with a spinner indicating the load. //! //! [`ScreenIntent`] is the pure return type from state handlers — they //! never spawn async tasks directly. The intent is interpreted by //! [`LoreApp`](crate::app::LoreApp) which dispatches through the //! [`TaskSupervisor`](crate::task_supervisor::TaskSupervisor). pub mod bootstrap; pub mod command_palette; pub mod dashboard; pub mod file_history; pub mod issue_detail; pub mod issue_list; pub mod mr_detail; pub mod mr_list; pub mod search; pub mod sync; pub mod timeline; pub mod trace; pub mod who; use std::collections::{HashMap, HashSet}; use crate::message::Screen; // Re-export screen states for convenience. pub use bootstrap::BootstrapState; pub use command_palette::CommandPaletteState; pub use dashboard::DashboardState; pub use file_history::FileHistoryState; pub use issue_detail::IssueDetailState; pub use issue_list::IssueListState; pub use mr_detail::MrDetailState; pub use mr_list::MrListState; pub use search::SearchState; pub use sync::SyncState; pub use timeline::TimelineState; pub use trace::TraceState; pub use who::WhoState; // --------------------------------------------------------------------------- // LoadState // --------------------------------------------------------------------------- /// Loading state for a screen's data. /// /// Enables stale-while-revalidate: screens render their last data while /// showing a spinner when `Refreshing`. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum LoadState { /// No load in progress, data is current (or screen was never loaded). #[default] Idle, /// First load — no data to show yet, display a full-screen spinner. LoadingInitial, /// Background refresh — show existing data with a spinner indicator. Refreshing, /// Load failed — display the error alongside any stale data. Error(String), } impl LoadState { /// Whether data is currently being loaded. #[must_use] pub fn is_loading(&self) -> bool { matches!(self, Self::LoadingInitial | Self::Refreshing) } } // --------------------------------------------------------------------------- // ScreenLoadStateMap // --------------------------------------------------------------------------- /// Tracks per-screen load state. /// /// Returns [`LoadState::Idle`] for screens that haven't been tracked. /// Automatically removes entries set to `Idle` to prevent unbounded growth. #[derive(Debug, Default)] pub struct ScreenLoadStateMap { map: HashMap, /// Screens that have had a load state set at least once. visited: HashSet, } impl ScreenLoadStateMap { /// Get the load state for a screen (defaults to `Idle`). #[must_use] pub fn get(&self, screen: &Screen) -> &LoadState { static IDLE: LoadState = LoadState::Idle; self.map.get(screen).unwrap_or(&IDLE) } /// Set the load state for a screen. /// /// Setting to `Idle` removes the entry to prevent map growth. pub fn set(&mut self, screen: Screen, state: LoadState) { self.visited.insert(screen.clone()); if state == LoadState::Idle { self.map.remove(&screen); } else { self.map.insert(screen, state); } } /// Whether this screen has ever had a load initiated. #[must_use] pub fn was_visited(&self, screen: &Screen) -> bool { self.visited.contains(screen) } /// Whether any screen is currently loading. #[must_use] pub fn any_loading(&self) -> bool { self.map.values().any(LoadState::is_loading) } } // --------------------------------------------------------------------------- // ScreenIntent // --------------------------------------------------------------------------- /// Pure return type from screen state handlers. /// /// State handlers must never spawn async work directly — they return /// an intent that [`LoreApp`] interprets and dispatches through the /// [`TaskSupervisor`]. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ScreenIntent { /// No action needed. None, /// Navigate to a new screen. Navigate(Screen), /// Screen data needs re-querying (e.g., filter changed). RequeryNeeded(Screen), } // --------------------------------------------------------------------------- // ScopeContext // --------------------------------------------------------------------------- /// Global scope filters applied across all screens. /// /// When a project filter is active, all data queries scope to that /// project. The TUI shows the active scope in the status bar. #[derive(Debug, Default)] pub struct ScopeContext { /// Active project filter (project_id). pub project_id: Option, /// Human-readable project name for display. pub project_name: Option, } // --------------------------------------------------------------------------- // AppState // --------------------------------------------------------------------------- /// Top-level state composition for the TUI. /// /// Each field holds one screen's state. State is preserved when /// navigating away and restored on return. #[derive(Debug, Default)] pub struct AppState { // Per-screen states. pub bootstrap: BootstrapState, pub dashboard: DashboardState, pub issue_list: IssueListState, pub issue_detail: IssueDetailState, pub mr_list: MrListState, pub mr_detail: MrDetailState, pub search: SearchState, pub timeline: TimelineState, pub who: WhoState, pub trace: TraceState, pub file_history: FileHistoryState, pub sync: SyncState, pub command_palette: CommandPaletteState, // Cross-cutting state. pub global_scope: ScopeContext, pub load_state: ScreenLoadStateMap, pub error_toast: Option, pub show_help: bool, pub terminal_size: (u16, u16), } impl AppState { /// Set a screen's load state. pub fn set_loading(&mut self, screen: Screen, state: LoadState) { self.load_state.set(screen, state); } /// Set the global error toast. pub fn set_error(&mut self, msg: String) { self.error_toast = Some(msg); } /// Clear the global error toast. pub fn clear_error(&mut self) { self.error_toast = None; } /// Whether any text input is currently focused. #[must_use] pub fn has_text_focus(&self) -> bool { self.issue_list.filter_focused || self.mr_list.filter_focused || self.search.query_focused || self.command_palette.query_focused || self.who.has_text_focus() || self.trace.has_text_focus() || self.file_history.has_text_focus() } /// Remove focus from all text inputs. pub fn blur_text_focus(&mut self) { self.issue_list.filter_focused = false; self.mr_list.filter_focused = false; self.search.query_focused = false; self.command_palette.query_focused = false; self.who.blur(); self.trace.blur(); self.file_history.blur(); } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn test_load_state_default_idle() { let map = ScreenLoadStateMap::default(); assert_eq!(*map.get(&Screen::Dashboard), LoadState::Idle); assert_eq!(*map.get(&Screen::IssueList), LoadState::Idle); } #[test] fn test_load_state_set_and_get() { let mut map = ScreenLoadStateMap::default(); map.set(Screen::Dashboard, LoadState::LoadingInitial); assert_eq!(*map.get(&Screen::Dashboard), LoadState::LoadingInitial); assert_eq!(*map.get(&Screen::IssueList), LoadState::Idle); } #[test] fn test_load_state_set_idle_removes_entry() { let mut map = ScreenLoadStateMap::default(); map.set(Screen::Dashboard, LoadState::Refreshing); assert_eq!(map.map.len(), 1); map.set(Screen::Dashboard, LoadState::Idle); assert_eq!(map.map.len(), 0); assert_eq!(*map.get(&Screen::Dashboard), LoadState::Idle); } #[test] fn test_any_loading() { let mut map = ScreenLoadStateMap::default(); assert!(!map.any_loading()); map.set(Screen::Dashboard, LoadState::LoadingInitial); assert!(map.any_loading()); map.set(Screen::Dashboard, LoadState::Error("oops".into())); assert!(!map.any_loading()); } #[test] fn test_load_state_is_loading() { assert!(!LoadState::Idle.is_loading()); assert!(LoadState::LoadingInitial.is_loading()); assert!(LoadState::Refreshing.is_loading()); assert!(!LoadState::Error("x".into()).is_loading()); } #[test] fn test_app_state_default_compiles() { let state = AppState::default(); assert!(!state.show_help); assert!(state.error_toast.is_none()); assert_eq!(state.terminal_size, (0, 0)); } #[test] fn test_app_state_set_error_and_clear() { let mut state = AppState::default(); state.set_error("db busy".into()); assert_eq!(state.error_toast.as_deref(), Some("db busy")); state.clear_error(); assert!(state.error_toast.is_none()); } #[test] fn test_app_state_has_text_focus() { let mut state = AppState::default(); assert!(!state.has_text_focus()); state.search.query_focused = true; assert!(state.has_text_focus()); } #[test] fn test_app_state_blur_text_focus() { let mut state = AppState::default(); state.issue_list.filter_focused = true; state.mr_list.filter_focused = true; state.search.query_focused = true; state.command_palette.query_focused = true; state.blur_text_focus(); assert!(!state.has_text_focus()); assert!(!state.issue_list.filter_focused); assert!(!state.mr_list.filter_focused); assert!(!state.search.query_focused); assert!(!state.command_palette.query_focused); } #[test] fn test_app_state_set_loading() { let mut state = AppState::default(); state.set_loading(Screen::IssueList, LoadState::Refreshing); assert_eq!( *state.load_state.get(&Screen::IssueList), LoadState::Refreshing ); } #[test] fn test_screen_intent_variants() { let none = ScreenIntent::None; let nav = ScreenIntent::Navigate(Screen::IssueList); let requery = ScreenIntent::RequeryNeeded(Screen::Search); assert_eq!(none, ScreenIntent::None); assert_eq!(nav, ScreenIntent::Navigate(Screen::IssueList)); assert_eq!(requery, ScreenIntent::RequeryNeeded(Screen::Search)); } #[test] fn test_scope_context_default() { let scope = ScopeContext::default(); assert!(scope.project_id.is_none()); assert!(scope.project_name.is_none()); } }