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
360 lines
11 KiB
Rust
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());
|
|
}
|
|
}
|