Files
gitlore/crates/lore-tui/src/state/mod.rs
teernisse fb40fdc677 feat(tui): Phase 3 power features — Who, Search, Timeline, Trace, File History screens
Complete TUI Phase 3 implementation with all 5 power feature screens:

- Who screen: 5 modes (expert/workload/reviews/active/overlap) with
  mode tabs, input bar, result rendering, and hint bar
- Search screen: full-text search with result list and scoring display
- Timeline screen: chronological event feed with time-relative display
- Trace screen: file provenance chains with expand/collapse, rename
  tracking, and linked issues/discussions
- File History screen: per-file MR timeline with rename chain display
  and discussion snippets

Also includes:
- Command palette overlay (fuzzy search)
- Bootstrap screen (initial sync flow)
- Action layer split from monolithic action.rs to per-screen modules
- Entity and render cache infrastructure
- Shared who_types module in core crate
- All screens wired into view/mod.rs dispatch
- 597 tests passing, clippy clean (pedantic + nursery), fmt clean
2026-02-18 22:56:38 -05:00

360 lines
11 KiB
Rust

#![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<Screen, LoadState>,
/// Screens that have had a load state set at least once.
visited: HashSet<Screen>,
}
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<i64>,
/// Human-readable project name for display.
pub project_name: Option<String>,
}
// ---------------------------------------------------------------------------
// 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<String>,
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());
}
}