feat: complete TUI Phase 0 — Toolchain Gate
Implements the full lore-tui crate scaffold with 6 Phase 0 modules: - message.rs: Msg (~40 variants), Screen (12), EntityKey, AppError, InputMode - clock.rs: Clock trait with SystemClock + FakeClock for deterministic testing - safety.rs: Terminal sanitizer (ANSI filter), URL policy, PII/secret redaction - db.rs: DbManager with 3 reader pool (round-robin) + dedicated writer (WAL mode) - theme.rs: Flexoki adaptive theme (19 slots), state/event colors, label styling - app.rs: Minimal LoreApp Model trait impl proving FrankenTUI integration 68 tests, clippy clean, fmt clean. Closes bd-3ddw, bd-c9gk, bd-2lg6, bd-3ir1, bd-2kop, bd-5ofk, bd-2emv, bd-1cj0.
This commit is contained in:
523
crates/lore-tui/src/message.rs
Normal file
523
crates/lore-tui/src/message.rs
Normal file
@@ -0,0 +1,523 @@
|
||||
#![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 std::time::Instant;
|
||||
|
||||
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)]
|
||||
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: Instant },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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))));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user