bd-26f2: Common widgets (render_breadcrumb, render_status_bar, render_loading, render_error_toast, render_help_overlay) + render_screen top-level dispatch wired to LoreApp::view(). 27 widget tests. bd-2w1p: Add half-life fields to ScoringConfig with validation. bd-1soz: Add half_life_decay() pure function. bd-18dn: Add normalize_query_path() for path canonicalization. Phase 1 modules: CommandRegistry, NavigationStack, CrashContext, TaskSupervisor, AppState with per-screen states. 172 lore-tui tests passing, clippy clean, fmt clean.
524 lines
14 KiB
Rust
524 lines
14 KiB
Rust
#![allow(dead_code)] // Phase 0: types defined now, consumed in Phase 1+
|
|
|
|
//! Core types for the lore-tui Elm architecture.
|
|
//!
|
|
//! - [`Msg`] — every user action and async result flows through this enum.
|
|
//! - [`Screen`] — navigation targets.
|
|
//! - [`EntityKey`] — safe cross-project entity identity.
|
|
//! - [`AppError`] — structured error display in the TUI.
|
|
//! - [`InputMode`] — controls key dispatch routing.
|
|
|
|
use std::fmt;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use ftui::Event;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EntityKind
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Distinguishes issue vs merge request in an [`EntityKey`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum EntityKind {
|
|
Issue,
|
|
MergeRequest,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EntityKey
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Uniquely identifies an entity (issue or MR) across projects.
|
|
///
|
|
/// Bare `iid` is unsafe in multi-project datasets — equality requires
|
|
/// project_id + iid + kind.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct EntityKey {
|
|
pub project_id: i64,
|
|
pub iid: i64,
|
|
pub kind: EntityKind,
|
|
}
|
|
|
|
impl EntityKey {
|
|
#[must_use]
|
|
pub fn issue(project_id: i64, iid: i64) -> Self {
|
|
Self {
|
|
project_id,
|
|
iid,
|
|
kind: EntityKind::Issue,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn mr(project_id: i64, iid: i64) -> Self {
|
|
Self {
|
|
project_id,
|
|
iid,
|
|
kind: EntityKind::MergeRequest,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for EntityKey {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let prefix = match self.kind {
|
|
EntityKind::Issue => "#",
|
|
EntityKind::MergeRequest => "!",
|
|
};
|
|
write!(f, "p{}:{}{}", self.project_id, prefix, self.iid)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Screen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Navigation targets within the TUI.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub enum Screen {
|
|
Dashboard,
|
|
IssueList,
|
|
IssueDetail(EntityKey),
|
|
MrList,
|
|
MrDetail(EntityKey),
|
|
Search,
|
|
Timeline,
|
|
Who,
|
|
Sync,
|
|
Stats,
|
|
Doctor,
|
|
Bootstrap,
|
|
}
|
|
|
|
impl Screen {
|
|
/// Human-readable label for breadcrumbs and status bar.
|
|
#[must_use]
|
|
pub fn label(&self) -> &str {
|
|
match self {
|
|
Self::Dashboard => "Dashboard",
|
|
Self::IssueList => "Issues",
|
|
Self::IssueDetail(_) => "Issue",
|
|
Self::MrList => "Merge Requests",
|
|
Self::MrDetail(_) => "Merge Request",
|
|
Self::Search => "Search",
|
|
Self::Timeline => "Timeline",
|
|
Self::Who => "Who",
|
|
Self::Sync => "Sync",
|
|
Self::Stats => "Stats",
|
|
Self::Doctor => "Doctor",
|
|
Self::Bootstrap => "Bootstrap",
|
|
}
|
|
}
|
|
|
|
/// Whether this screen shows a specific entity detail view.
|
|
#[must_use]
|
|
pub fn is_detail_or_entity(&self) -> bool {
|
|
matches!(self, Self::IssueDetail(_) | Self::MrDetail(_))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AppError
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Structured error types for user-facing display in the TUI.
|
|
#[derive(Debug, Clone)]
|
|
pub enum AppError {
|
|
/// Database is busy (WAL contention).
|
|
DbBusy,
|
|
/// Database corruption detected.
|
|
DbCorruption(String),
|
|
/// GitLab rate-limited; retry after N seconds (if header present).
|
|
NetworkRateLimited { retry_after_secs: Option<u64> },
|
|
/// Network unavailable.
|
|
NetworkUnavailable,
|
|
/// GitLab authentication failed.
|
|
AuthFailed,
|
|
/// Data parsing error.
|
|
ParseError(String),
|
|
/// Internal / unexpected error.
|
|
Internal(String),
|
|
}
|
|
|
|
impl fmt::Display for AppError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::DbBusy => write!(f, "Database is busy — another process holds the lock"),
|
|
Self::DbCorruption(detail) => write!(f, "Database corruption: {detail}"),
|
|
Self::NetworkRateLimited {
|
|
retry_after_secs: Some(secs),
|
|
} => write!(f, "Rate limited by GitLab — retry in {secs}s"),
|
|
Self::NetworkRateLimited {
|
|
retry_after_secs: None,
|
|
} => write!(f, "Rate limited by GitLab — try again shortly"),
|
|
Self::NetworkUnavailable => write!(f, "Network unavailable — working offline"),
|
|
Self::AuthFailed => write!(f, "GitLab authentication failed — check your token"),
|
|
Self::ParseError(detail) => write!(f, "Parse error: {detail}"),
|
|
Self::Internal(detail) => write!(f, "Internal error: {detail}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// InputMode
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Controls how keystrokes are routed through the key dispatch pipeline.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub enum InputMode {
|
|
/// Standard navigation mode — keys dispatch to screen-specific handlers.
|
|
#[default]
|
|
Normal,
|
|
/// Text input focused (filter bar, search box).
|
|
Text,
|
|
/// Command palette is open.
|
|
Palette,
|
|
/// "g" prefix pressed — waiting for second key (500ms timeout).
|
|
GoPrefix { started_at: DateTime<Utc> },
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Msg
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Every user action and async result flows through this enum.
|
|
///
|
|
/// Generation fields (`generation: u64`) on async result variants enable
|
|
/// stale-response detection: if the generation doesn't match the current
|
|
/// request generation, the result is silently dropped.
|
|
#[derive(Debug)]
|
|
pub enum Msg {
|
|
// --- Terminal events ---
|
|
/// Raw terminal event (key, mouse, paste, focus, clipboard).
|
|
RawEvent(Event),
|
|
/// Periodic tick from runtime subscription.
|
|
Tick,
|
|
/// Terminal resized.
|
|
Resize {
|
|
width: u16,
|
|
height: u16,
|
|
},
|
|
|
|
// --- Navigation ---
|
|
/// Navigate to a specific screen.
|
|
NavigateTo(Screen),
|
|
/// Go back in navigation history.
|
|
GoBack,
|
|
/// Go forward in navigation history.
|
|
GoForward,
|
|
/// Jump to the dashboard.
|
|
GoHome,
|
|
/// Jump back N screens in history.
|
|
JumpBack(usize),
|
|
/// Jump forward N screens in history.
|
|
JumpForward(usize),
|
|
|
|
// --- Command palette ---
|
|
OpenCommandPalette,
|
|
CloseCommandPalette,
|
|
CommandPaletteInput(String),
|
|
CommandPaletteSelect(String),
|
|
|
|
// --- Issue list ---
|
|
IssueListLoaded {
|
|
generation: u64,
|
|
rows: Vec<IssueRow>,
|
|
},
|
|
IssueListFilterChanged(String),
|
|
IssueListSortChanged,
|
|
IssueSelected(EntityKey),
|
|
|
|
// --- MR list ---
|
|
MrListLoaded {
|
|
generation: u64,
|
|
rows: Vec<MrRow>,
|
|
},
|
|
MrListFilterChanged(String),
|
|
MrSelected(EntityKey),
|
|
|
|
// --- Issue detail ---
|
|
IssueDetailLoaded {
|
|
generation: u64,
|
|
key: EntityKey,
|
|
detail: Box<IssueDetail>,
|
|
},
|
|
|
|
// --- MR detail ---
|
|
MrDetailLoaded {
|
|
generation: u64,
|
|
key: EntityKey,
|
|
detail: Box<MrDetail>,
|
|
},
|
|
|
|
// --- Discussions (shared by issue + MR detail) ---
|
|
DiscussionsLoaded {
|
|
generation: u64,
|
|
discussions: Vec<Discussion>,
|
|
},
|
|
|
|
// --- Search ---
|
|
SearchQueryChanged(String),
|
|
SearchRequestStarted {
|
|
generation: u64,
|
|
query: String,
|
|
},
|
|
SearchExecuted {
|
|
generation: u64,
|
|
results: Vec<SearchResult>,
|
|
},
|
|
SearchResultSelected(EntityKey),
|
|
SearchModeChanged,
|
|
SearchCapabilitiesLoaded,
|
|
|
|
// --- Timeline ---
|
|
TimelineLoaded {
|
|
generation: u64,
|
|
events: Vec<TimelineEvent>,
|
|
},
|
|
TimelineEntitySelected(EntityKey),
|
|
|
|
// --- Who (people) ---
|
|
WhoResultLoaded {
|
|
generation: u64,
|
|
result: Box<WhoResult>,
|
|
},
|
|
WhoModeChanged,
|
|
|
|
// --- Sync ---
|
|
SyncStarted,
|
|
SyncProgress {
|
|
stage: String,
|
|
current: u64,
|
|
total: u64,
|
|
},
|
|
SyncProgressBatch {
|
|
stage: String,
|
|
batch_size: u64,
|
|
},
|
|
SyncLogLine(String),
|
|
SyncBackpressureDrop,
|
|
SyncCompleted {
|
|
elapsed_ms: u64,
|
|
},
|
|
SyncCancelled,
|
|
SyncFailed(String),
|
|
SyncStreamStats {
|
|
bytes: u64,
|
|
items: u64,
|
|
},
|
|
|
|
// --- Search debounce ---
|
|
SearchDebounceArmed {
|
|
generation: u64,
|
|
},
|
|
SearchDebounceFired {
|
|
generation: u64,
|
|
},
|
|
|
|
// --- Dashboard ---
|
|
DashboardLoaded {
|
|
generation: u64,
|
|
data: Box<DashboardData>,
|
|
},
|
|
|
|
// --- Global actions ---
|
|
Error(AppError),
|
|
ShowHelp,
|
|
ShowCliEquivalent,
|
|
OpenInBrowser,
|
|
BlurTextInput,
|
|
ScrollToTopCurrentScreen,
|
|
Quit,
|
|
}
|
|
|
|
/// Convert terminal events into messages.
|
|
///
|
|
/// FrankenTUI requires `From<Event>` on the message type so the runtime
|
|
/// can inject terminal events into the model's update loop.
|
|
impl From<Event> for Msg {
|
|
fn from(event: Event) -> Self {
|
|
match event {
|
|
Event::Resize { width, height } => Self::Resize { width, height },
|
|
Event::Tick => Self::Tick,
|
|
other => Self::RawEvent(other),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Placeholder data types (will be fleshed out in Phase 1+)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Placeholder for an issue row in list views.
|
|
#[derive(Debug, Clone)]
|
|
pub struct IssueRow {
|
|
pub key: EntityKey,
|
|
pub title: String,
|
|
pub state: String,
|
|
}
|
|
|
|
/// Placeholder for a merge request row in list views.
|
|
#[derive(Debug, Clone)]
|
|
pub struct MrRow {
|
|
pub key: EntityKey,
|
|
pub title: String,
|
|
pub state: String,
|
|
pub draft: bool,
|
|
}
|
|
|
|
/// Placeholder for issue detail payload.
|
|
#[derive(Debug, Clone)]
|
|
pub struct IssueDetail {
|
|
pub key: EntityKey,
|
|
pub title: String,
|
|
pub description: String,
|
|
}
|
|
|
|
/// Placeholder for MR detail payload.
|
|
#[derive(Debug, Clone)]
|
|
pub struct MrDetail {
|
|
pub key: EntityKey,
|
|
pub title: String,
|
|
pub description: String,
|
|
}
|
|
|
|
/// Placeholder for a discussion thread.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Discussion {
|
|
pub id: String,
|
|
pub notes: Vec<String>,
|
|
}
|
|
|
|
/// Placeholder for a search result.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SearchResult {
|
|
pub key: EntityKey,
|
|
pub title: String,
|
|
pub score: f64,
|
|
}
|
|
|
|
/// Placeholder for a timeline event.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TimelineEvent {
|
|
pub timestamp: String,
|
|
pub description: String,
|
|
}
|
|
|
|
/// Placeholder for who/people intelligence result.
|
|
#[derive(Debug, Clone)]
|
|
pub struct WhoResult {
|
|
pub experts: Vec<String>,
|
|
}
|
|
|
|
/// Placeholder for dashboard summary data.
|
|
#[derive(Debug, Clone)]
|
|
pub struct DashboardData {
|
|
pub issue_count: u64,
|
|
pub mr_count: u64,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_entity_key_equality() {
|
|
assert_eq!(EntityKey::issue(1, 42), EntityKey::issue(1, 42));
|
|
assert_ne!(EntityKey::issue(1, 42), EntityKey::mr(1, 42));
|
|
}
|
|
|
|
#[test]
|
|
fn test_entity_key_different_projects() {
|
|
assert_ne!(EntityKey::issue(1, 42), EntityKey::issue(2, 42));
|
|
}
|
|
|
|
#[test]
|
|
fn test_entity_key_display() {
|
|
assert_eq!(EntityKey::issue(5, 123).to_string(), "p5:#123");
|
|
assert_eq!(EntityKey::mr(5, 456).to_string(), "p5:!456");
|
|
}
|
|
|
|
#[test]
|
|
fn test_entity_key_hash_is_usable_in_collections() {
|
|
use std::collections::HashSet;
|
|
let mut set = HashSet::new();
|
|
set.insert(EntityKey::issue(1, 1));
|
|
set.insert(EntityKey::issue(1, 1)); // duplicate
|
|
set.insert(EntityKey::mr(1, 1));
|
|
assert_eq!(set.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_screen_labels() {
|
|
assert_eq!(Screen::Dashboard.label(), "Dashboard");
|
|
assert_eq!(Screen::IssueList.label(), "Issues");
|
|
assert_eq!(Screen::MrList.label(), "Merge Requests");
|
|
assert_eq!(Screen::Search.label(), "Search");
|
|
}
|
|
|
|
#[test]
|
|
fn test_screen_is_detail_or_entity() {
|
|
assert!(Screen::IssueDetail(EntityKey::issue(1, 1)).is_detail_or_entity());
|
|
assert!(Screen::MrDetail(EntityKey::mr(1, 1)).is_detail_or_entity());
|
|
assert!(!Screen::Dashboard.is_detail_or_entity());
|
|
assert!(!Screen::IssueList.is_detail_or_entity());
|
|
assert!(!Screen::Search.is_detail_or_entity());
|
|
}
|
|
|
|
#[test]
|
|
fn test_app_error_display() {
|
|
let err = AppError::DbBusy;
|
|
assert!(err.to_string().contains("busy"));
|
|
|
|
let err = AppError::NetworkRateLimited {
|
|
retry_after_secs: Some(30),
|
|
};
|
|
assert!(err.to_string().contains("30s"));
|
|
|
|
let err = AppError::NetworkRateLimited {
|
|
retry_after_secs: None,
|
|
};
|
|
assert!(err.to_string().contains("shortly"));
|
|
|
|
let err = AppError::AuthFailed;
|
|
assert!(err.to_string().contains("token"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_input_mode_default_is_normal() {
|
|
assert!(matches!(InputMode::default(), InputMode::Normal));
|
|
}
|
|
|
|
#[test]
|
|
fn test_msg_from_event_resize() {
|
|
let event = Event::Resize {
|
|
width: 80,
|
|
height: 24,
|
|
};
|
|
let msg = Msg::from(event);
|
|
assert!(matches!(
|
|
msg,
|
|
Msg::Resize {
|
|
width: 80,
|
|
height: 24
|
|
}
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_msg_from_event_tick() {
|
|
let msg = Msg::from(Event::Tick);
|
|
assert!(matches!(msg, Msg::Tick));
|
|
}
|
|
|
|
#[test]
|
|
fn test_msg_from_event_focus_wraps_raw() {
|
|
let msg = Msg::from(Event::Focus(true));
|
|
assert!(matches!(msg, Msg::RawEvent(Event::Focus(true))));
|
|
}
|
|
}
|