feat: TUI Phase 1 common widgets + scoring/path beads
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.
This commit is contained in:
312
.beads/.br_history/issues.20260212_211122.jsonl
Normal file
312
.beads/.br_history/issues.20260212_211122.jsonl
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
bd-20p9
|
||||
bd-26f2
|
||||
|
||||
3
crates/lore-tui/Cargo.lock
generated
3
crates/lore-tui/Cargo.lock
generated
@@ -1335,6 +1335,9 @@ dependencies = [
|
||||
"lore",
|
||||
"regex",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -35,5 +35,12 @@ rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
# Terminal (crossterm for raw mode + event reading, used by ftui runtime)
|
||||
crossterm = "0.28"
|
||||
|
||||
# Serialization (crash context NDJSON dumps)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Regex (used by safety module for PII/secret redaction)
|
||||
regex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
#![allow(dead_code)] // Phase 0: minimal scaffold, fleshed out in bd-6pmy
|
||||
#![allow(dead_code)] // Phase 1: methods consumed as screens are implemented
|
||||
|
||||
//! Minimal FrankenTUI Model implementation for the lore TUI.
|
||||
//! Full FrankenTUI Model implementation for the lore TUI.
|
||||
//!
|
||||
//! This is the Phase 0 integration proof — validates that the ftui Model trait
|
||||
//! compiles with our Msg type and produces basic output. The full LoreApp with
|
||||
//! screen routing, navigation stack, and action dispatch comes in bd-6pmy.
|
||||
//! LoreApp is the central coordinator: it owns all state, dispatches
|
||||
//! messages through a 5-stage key pipeline, records crash context
|
||||
//! breadcrumbs, manages async tasks via the supervisor, and routes
|
||||
//! view() to per-screen render functions.
|
||||
|
||||
use ftui::{Cmd, Frame, Model};
|
||||
use chrono::TimeDelta;
|
||||
use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers};
|
||||
|
||||
use crate::message::Msg;
|
||||
use crate::clock::{Clock, SystemClock};
|
||||
use crate::commands::{CommandRegistry, build_registry};
|
||||
use crate::crash_context::{CrashContext, CrashEvent};
|
||||
use crate::db::DbManager;
|
||||
use crate::message::{InputMode, Msg, Screen};
|
||||
use crate::navigation::NavigationStack;
|
||||
use crate::state::{AppState, LoadState};
|
||||
use crate::task_supervisor::{TaskKey, TaskSupervisor};
|
||||
|
||||
/// Timeout for the g-prefix key sequence.
|
||||
const GO_PREFIX_TIMEOUT: TimeDelta = TimeDelta::milliseconds(500);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LoreApp
|
||||
@@ -16,49 +28,414 @@ use crate::message::Msg;
|
||||
|
||||
/// Root model for the lore TUI.
|
||||
///
|
||||
/// Phase 0: minimal scaffold that renders a placeholder and handles Quit.
|
||||
/// Phase 1 (bd-6pmy) will add screen routing, DbManager, theme, and subscriptions.
|
||||
pub struct LoreApp;
|
||||
/// Owns all state and implements the FrankenTUI Model trait. The
|
||||
/// update() method is the single entry point for all state transitions.
|
||||
pub struct LoreApp {
|
||||
pub state: AppState,
|
||||
pub navigation: NavigationStack,
|
||||
pub supervisor: TaskSupervisor,
|
||||
pub crash_context: CrashContext,
|
||||
pub command_registry: CommandRegistry,
|
||||
pub input_mode: InputMode,
|
||||
pub clock: Box<dyn Clock>,
|
||||
pub db: Option<DbManager>,
|
||||
}
|
||||
|
||||
impl LoreApp {
|
||||
/// Create a new LoreApp with default state.
|
||||
///
|
||||
/// Uses a real system clock and no DB connection (set separately).
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: AppState::default(),
|
||||
navigation: NavigationStack::new(),
|
||||
supervisor: TaskSupervisor::new(),
|
||||
crash_context: CrashContext::new(),
|
||||
command_registry: build_registry(),
|
||||
input_mode: InputMode::Normal,
|
||||
clock: Box::new(SystemClock),
|
||||
db: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a LoreApp for testing with a custom clock.
|
||||
#[cfg(test)]
|
||||
fn with_clock(clock: Box<dyn Clock>) -> Self {
|
||||
Self {
|
||||
clock,
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Key dispatch
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Normalize terminal key variants for cross-terminal consistency.
|
||||
fn normalize_key(key: &mut KeyEvent) {
|
||||
// BackTab -> Shift+Tab canonical form.
|
||||
if key.code == KeyCode::BackTab {
|
||||
key.code = KeyCode::Tab;
|
||||
key.modifiers |= Modifiers::SHIFT;
|
||||
}
|
||||
}
|
||||
|
||||
/// 5-stage key dispatch pipeline.
|
||||
///
|
||||
/// Returns the Cmd to execute (Quit, None, or a task command).
|
||||
fn interpret_key(&mut self, mut key: KeyEvent) -> Cmd<Msg> {
|
||||
Self::normalize_key(&mut key);
|
||||
|
||||
let screen = self.navigation.current().clone();
|
||||
|
||||
// Record key press in crash context.
|
||||
self.crash_context.push(CrashEvent::KeyPress {
|
||||
key: format!("{:?}", key.code),
|
||||
mode: format!("{:?}", self.input_mode),
|
||||
screen: screen.label().to_string(),
|
||||
});
|
||||
|
||||
// --- Stage 1: Quit check ---
|
||||
// Ctrl+C always quits regardless of mode.
|
||||
if key.code == KeyCode::Char('c') && key.modifiers.contains(Modifiers::CTRL) {
|
||||
return Cmd::quit();
|
||||
}
|
||||
|
||||
// --- Stage 2: InputMode routing ---
|
||||
match &self.input_mode {
|
||||
InputMode::Text => {
|
||||
return self.handle_text_mode_key(&key, &screen);
|
||||
}
|
||||
InputMode::Palette => {
|
||||
return self.handle_palette_mode_key(&key, &screen);
|
||||
}
|
||||
InputMode::GoPrefix { started_at } => {
|
||||
let elapsed = self.clock.now().signed_duration_since(*started_at);
|
||||
if elapsed > GO_PREFIX_TIMEOUT {
|
||||
// Timeout expired — cancel prefix and re-process as normal.
|
||||
self.input_mode = InputMode::Normal;
|
||||
} else {
|
||||
return self.handle_go_prefix_key(&key, &screen);
|
||||
}
|
||||
}
|
||||
InputMode::Normal => {}
|
||||
}
|
||||
|
||||
// --- Stage 3: Global shortcuts (Normal mode) ---
|
||||
// 'q' quits.
|
||||
if key.code == KeyCode::Char('q') && key.modifiers == Modifiers::NONE {
|
||||
return Cmd::quit();
|
||||
}
|
||||
|
||||
// 'g' starts prefix sequence.
|
||||
if self
|
||||
.command_registry
|
||||
.is_sequence_starter(&key.code, &key.modifiers)
|
||||
{
|
||||
self.input_mode = InputMode::GoPrefix {
|
||||
started_at: self.clock.now(),
|
||||
};
|
||||
return Cmd::none();
|
||||
}
|
||||
|
||||
// Registry-based single-key lookup.
|
||||
if let Some(cmd_def) =
|
||||
self.command_registry
|
||||
.lookup_key(&key.code, &key.modifiers, &screen, &self.input_mode)
|
||||
{
|
||||
return self.execute_command(cmd_def.id, &screen);
|
||||
}
|
||||
|
||||
// --- Stage 4: Screen-local keys ---
|
||||
// Delegated to AppState::interpret_screen_key in future phases.
|
||||
|
||||
// --- Stage 5: Fallback (unhandled) ---
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
/// Handle keys in Text input mode.
|
||||
///
|
||||
/// Only Esc and Ctrl+P pass through; everything else is consumed by
|
||||
/// the focused text widget (handled in future phases).
|
||||
fn handle_text_mode_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
|
||||
// Esc blurs the text input.
|
||||
if key.code == KeyCode::Escape {
|
||||
self.state.blur_text_focus();
|
||||
self.input_mode = InputMode::Normal;
|
||||
return Cmd::none();
|
||||
}
|
||||
|
||||
// Ctrl+P opens palette even in text mode.
|
||||
if let Some(cmd_def) =
|
||||
self.command_registry
|
||||
.lookup_key(&key.code, &key.modifiers, screen, &InputMode::Text)
|
||||
{
|
||||
return self.execute_command(cmd_def.id, screen);
|
||||
}
|
||||
|
||||
// All other keys consumed by text widget (future).
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
/// Handle keys in Palette mode.
|
||||
fn handle_palette_mode_key(&mut self, key: &KeyEvent, _screen: &Screen) -> Cmd<Msg> {
|
||||
if key.code == KeyCode::Escape {
|
||||
self.input_mode = InputMode::Normal;
|
||||
return Cmd::none();
|
||||
}
|
||||
// Palette key dispatch will be expanded in the palette widget phase.
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
/// Handle the second key of a g-prefix sequence.
|
||||
fn handle_go_prefix_key(&mut self, key: &KeyEvent, screen: &Screen) -> Cmd<Msg> {
|
||||
self.input_mode = InputMode::Normal;
|
||||
|
||||
if let Some(cmd_def) = self.command_registry.complete_sequence(
|
||||
&KeyCode::Char('g'),
|
||||
&Modifiers::NONE,
|
||||
&key.code,
|
||||
&key.modifiers,
|
||||
screen,
|
||||
) {
|
||||
return self.execute_command(cmd_def.id, screen);
|
||||
}
|
||||
|
||||
// Invalid second key — cancel prefix silently.
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
/// Execute a command by ID.
|
||||
fn execute_command(&mut self, id: &str, _screen: &Screen) -> Cmd<Msg> {
|
||||
match id {
|
||||
"quit" => Cmd::quit(),
|
||||
"go_back" => {
|
||||
self.navigation.pop();
|
||||
Cmd::none()
|
||||
}
|
||||
"show_help" => {
|
||||
self.state.show_help = !self.state.show_help;
|
||||
Cmd::none()
|
||||
}
|
||||
"command_palette" => {
|
||||
self.input_mode = InputMode::Palette;
|
||||
self.state.command_palette.query_focused = true;
|
||||
Cmd::none()
|
||||
}
|
||||
"open_in_browser" => {
|
||||
// Will dispatch OpenInBrowser msg in future phase.
|
||||
Cmd::none()
|
||||
}
|
||||
"show_cli" => {
|
||||
// Will show CLI equivalent in future phase.
|
||||
Cmd::none()
|
||||
}
|
||||
"go_home" => self.navigate_to(Screen::Dashboard),
|
||||
"go_issues" => self.navigate_to(Screen::IssueList),
|
||||
"go_mrs" => self.navigate_to(Screen::MrList),
|
||||
"go_search" => self.navigate_to(Screen::Search),
|
||||
"go_timeline" => self.navigate_to(Screen::Timeline),
|
||||
"go_who" => self.navigate_to(Screen::Who),
|
||||
"go_sync" => self.navigate_to(Screen::Sync),
|
||||
"jump_back" => {
|
||||
self.navigation.jump_back();
|
||||
Cmd::none()
|
||||
}
|
||||
"jump_forward" => {
|
||||
self.navigation.jump_forward();
|
||||
Cmd::none()
|
||||
}
|
||||
"move_down" | "move_up" | "select_item" | "focus_filter" | "scroll_to_top" => {
|
||||
// Screen-specific actions — delegated in future phases.
|
||||
Cmd::none()
|
||||
}
|
||||
_ => Cmd::none(),
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Navigation helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Navigate to a screen, pushing the nav stack and starting a data load.
|
||||
fn navigate_to(&mut self, screen: Screen) -> Cmd<Msg> {
|
||||
let screen_label = screen.label().to_string();
|
||||
let current_label = self.navigation.current().label().to_string();
|
||||
|
||||
self.crash_context.push(CrashEvent::StateTransition {
|
||||
from: current_label,
|
||||
to: screen_label,
|
||||
});
|
||||
|
||||
self.navigation.push(screen.clone());
|
||||
self.state
|
||||
.set_loading(screen.clone(), LoadState::Refreshing);
|
||||
|
||||
// Spawn supervised task for data loading (placeholder — actual DB
|
||||
// query dispatch comes in Phase 2 screen implementations).
|
||||
let _handle = self.supervisor.submit(TaskKey::LoadScreen(screen));
|
||||
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Message dispatch (non-key)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Handle non-key messages.
|
||||
fn handle_msg(&mut self, msg: Msg) -> Cmd<Msg> {
|
||||
// Record in crash context.
|
||||
self.crash_context.push(CrashEvent::MsgDispatched {
|
||||
msg_name: format!("{msg:?}")
|
||||
.split('(')
|
||||
.next()
|
||||
.unwrap_or("?")
|
||||
.to_string(),
|
||||
screen: self.navigation.current().label().to_string(),
|
||||
});
|
||||
|
||||
match msg {
|
||||
Msg::Quit => Cmd::quit(),
|
||||
|
||||
// --- Navigation ---
|
||||
Msg::NavigateTo(screen) => self.navigate_to(screen),
|
||||
Msg::GoBack => {
|
||||
self.navigation.pop();
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::GoForward => {
|
||||
self.navigation.go_forward();
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::GoHome => self.navigate_to(Screen::Dashboard),
|
||||
Msg::JumpBack(_) => {
|
||||
self.navigation.jump_back();
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::JumpForward(_) => {
|
||||
self.navigation.jump_forward();
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// --- Error ---
|
||||
Msg::Error(err) => {
|
||||
self.state.set_error(err.to_string());
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// --- Help / UI ---
|
||||
Msg::ShowHelp => {
|
||||
self.state.show_help = !self.state.show_help;
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::BlurTextInput => {
|
||||
self.state.blur_text_focus();
|
||||
self.input_mode = InputMode::Normal;
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// --- Terminal ---
|
||||
Msg::Resize { width, height } => {
|
||||
self.state.terminal_size = (width, height);
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::Tick => Cmd::none(),
|
||||
|
||||
// --- Loaded results (stale guard) ---
|
||||
Msg::IssueListLoaded { generation, rows } => {
|
||||
if self
|
||||
.supervisor
|
||||
.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation)
|
||||
{
|
||||
self.state.issue_list.rows = rows;
|
||||
self.state.set_loading(Screen::IssueList, LoadState::Idle);
|
||||
self.supervisor
|
||||
.complete(&TaskKey::LoadScreen(Screen::IssueList), generation);
|
||||
}
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::MrListLoaded { generation, rows } => {
|
||||
if self
|
||||
.supervisor
|
||||
.is_current(&TaskKey::LoadScreen(Screen::MrList), generation)
|
||||
{
|
||||
self.state.mr_list.rows = rows;
|
||||
self.state.set_loading(Screen::MrList, LoadState::Idle);
|
||||
self.supervisor
|
||||
.complete(&TaskKey::LoadScreen(Screen::MrList), generation);
|
||||
}
|
||||
Cmd::none()
|
||||
}
|
||||
Msg::DashboardLoaded { generation, data } => {
|
||||
if self
|
||||
.supervisor
|
||||
.is_current(&TaskKey::LoadScreen(Screen::Dashboard), generation)
|
||||
{
|
||||
self.state.dashboard.issue_count = data.issue_count;
|
||||
self.state.dashboard.mr_count = data.mr_count;
|
||||
self.state.set_loading(Screen::Dashboard, LoadState::Idle);
|
||||
self.supervisor
|
||||
.complete(&TaskKey::LoadScreen(Screen::Dashboard), generation);
|
||||
}
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
// All other message variants: no-op for now.
|
||||
// Future phases will fill these in as screens are implemented.
|
||||
_ => Cmd::none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LoreApp {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Model for LoreApp {
|
||||
type Message = Msg;
|
||||
|
||||
fn init(&mut self) -> Cmd<Self::Message> {
|
||||
// Install crash context panic hook.
|
||||
CrashContext::install_panic_hook(&self.crash_context);
|
||||
CrashContext::prune_crash_files();
|
||||
|
||||
// Navigate to dashboard (will trigger data load in future phase).
|
||||
Cmd::none()
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> Cmd<Self::Message> {
|
||||
match msg {
|
||||
Msg::Quit => Cmd::quit(),
|
||||
_ => Cmd::none(),
|
||||
}
|
||||
// Route raw key events through the 5-stage pipeline.
|
||||
if let Msg::RawEvent(Event::Key(key)) = msg {
|
||||
return self.interpret_key(key);
|
||||
}
|
||||
|
||||
fn view(&self, _frame: &mut Frame) {
|
||||
// Phase 0: no-op view. Phase 1 will render screens via the frame.
|
||||
// Everything else goes through message dispatch.
|
||||
self.handle_msg(msg)
|
||||
}
|
||||
|
||||
fn view(&self, frame: &mut Frame) {
|
||||
crate::view::render_screen(frame, self);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that `App::fullscreen(LoreApp).run()` compiles.
|
||||
///
|
||||
/// This is a compile-time check — we don't actually run it because that
|
||||
/// would require a real TTY. The function exists solely to prove the wiring.
|
||||
/// Verify that `App::fullscreen(LoreApp::new()).run()` compiles.
|
||||
#[cfg(test)]
|
||||
fn _assert_app_fullscreen_compiles() {
|
||||
// This function is never called — it only needs to compile.
|
||||
fn _inner() {
|
||||
use ftui::App;
|
||||
let _app_builder = App::fullscreen(LoreApp);
|
||||
// _app_builder.run() would need a TTY, so we don't call it.
|
||||
let _app_builder = App::fullscreen(LoreApp::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that `App::inline(LoreApp, 12).run()` compiles.
|
||||
/// Verify that `App::inline(LoreApp::new(), 12).run()` compiles.
|
||||
#[cfg(test)]
|
||||
fn _assert_app_inline_compiles() {
|
||||
fn _inner() {
|
||||
use ftui::App;
|
||||
let _app_builder = App::inline(LoreApp, 12);
|
||||
let _app_builder = App::inline(LoreApp::new(), 12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,33 +446,267 @@ fn _assert_app_inline_compiles() {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::clock::FakeClock;
|
||||
|
||||
fn test_app() -> LoreApp {
|
||||
LoreApp::with_clock(Box::new(FakeClock::new(chrono::Utc::now())))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_init_returns_none() {
|
||||
let mut app = LoreApp;
|
||||
let mut app = test_app();
|
||||
let cmd = app.init();
|
||||
assert!(matches!(cmd, Cmd::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_quit_returns_quit_cmd() {
|
||||
let mut app = LoreApp;
|
||||
let mut app = test_app();
|
||||
let cmd = app.update(Msg::Quit);
|
||||
assert!(matches!(cmd, Cmd::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_tick_returns_none() {
|
||||
let mut app = LoreApp;
|
||||
let mut app = test_app();
|
||||
let cmd = app.update(Msg::Tick);
|
||||
assert!(matches!(cmd, Cmd::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_navigate_returns_none() {
|
||||
use crate::message::Screen;
|
||||
let mut app = LoreApp;
|
||||
let cmd = app.update(Msg::NavigateTo(Screen::Dashboard));
|
||||
fn test_lore_app_navigate_to_updates_nav_stack() {
|
||||
let mut app = test_app();
|
||||
let cmd = app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
assert!(matches!(cmd, Cmd::None));
|
||||
assert!(app.navigation.is_at(&Screen::IssueList));
|
||||
assert_eq!(app.navigation.depth(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_go_back() {
|
||||
let mut app = test_app();
|
||||
app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
app.update(Msg::GoBack);
|
||||
assert!(app.navigation.is_at(&Screen::Dashboard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lore_app_go_forward() {
|
||||
let mut app = test_app();
|
||||
app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
app.update(Msg::GoBack);
|
||||
app.update(Msg::GoForward);
|
||||
assert!(app.navigation.is_at(&Screen::IssueList));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ctrl_c_always_quits() {
|
||||
let mut app = test_app();
|
||||
let key = KeyEvent::new(KeyCode::Char('c')).with_modifiers(Modifiers::CTRL);
|
||||
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
|
||||
assert!(matches!(cmd, Cmd::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_q_key_quits_in_normal_mode() {
|
||||
let mut app = test_app();
|
||||
let key = KeyEvent::new(KeyCode::Char('q'));
|
||||
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
|
||||
assert!(matches!(cmd, Cmd::Quit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_q_key_blocked_in_text_mode() {
|
||||
let mut app = test_app();
|
||||
app.input_mode = InputMode::Text;
|
||||
let key = KeyEvent::new(KeyCode::Char('q'));
|
||||
let cmd = app.update(Msg::RawEvent(Event::Key(key)));
|
||||
// q in text mode should NOT quit.
|
||||
assert!(matches!(cmd, Cmd::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_esc_blurs_text_mode() {
|
||||
let mut app = test_app();
|
||||
app.input_mode = InputMode::Text;
|
||||
app.state.search.query_focused = true;
|
||||
|
||||
let key = KeyEvent::new(KeyCode::Escape);
|
||||
app.update(Msg::RawEvent(Event::Key(key)));
|
||||
|
||||
assert!(matches!(app.input_mode, InputMode::Normal));
|
||||
assert!(!app.state.has_text_focus());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_g_prefix_enters_go_mode() {
|
||||
let mut app = test_app();
|
||||
let key = KeyEvent::new(KeyCode::Char('g'));
|
||||
app.update(Msg::RawEvent(Event::Key(key)));
|
||||
assert!(matches!(app.input_mode, InputMode::GoPrefix { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_g_then_i_navigates_to_issues() {
|
||||
let mut app = test_app();
|
||||
|
||||
// First key: 'g'
|
||||
let key_g = KeyEvent::new(KeyCode::Char('g'));
|
||||
app.update(Msg::RawEvent(Event::Key(key_g)));
|
||||
|
||||
// Second key: 'i'
|
||||
let key_i = KeyEvent::new(KeyCode::Char('i'));
|
||||
app.update(Msg::RawEvent(Event::Key(key_i)));
|
||||
|
||||
assert!(app.navigation.is_at(&Screen::IssueList));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_go_prefix_timeout_cancels() {
|
||||
let clock = FakeClock::new(chrono::Utc::now());
|
||||
let mut app = LoreApp::with_clock(Box::new(clock.clone()));
|
||||
|
||||
// Press 'g'.
|
||||
let key_g = KeyEvent::new(KeyCode::Char('g'));
|
||||
app.update(Msg::RawEvent(Event::Key(key_g)));
|
||||
assert!(matches!(app.input_mode, InputMode::GoPrefix { .. }));
|
||||
|
||||
// Advance clock past timeout.
|
||||
clock.advance(TimeDelta::milliseconds(600));
|
||||
|
||||
// Press 'i' after timeout — should NOT navigate to issues.
|
||||
let key_i = KeyEvent::new(KeyCode::Char('i'));
|
||||
app.update(Msg::RawEvent(Event::Key(key_i)));
|
||||
|
||||
// Should still be at Dashboard (no navigation happened).
|
||||
assert!(app.navigation.is_at(&Screen::Dashboard));
|
||||
assert!(matches!(app.input_mode, InputMode::Normal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_show_help_toggles() {
|
||||
let mut app = test_app();
|
||||
assert!(!app.state.show_help);
|
||||
|
||||
app.update(Msg::ShowHelp);
|
||||
assert!(app.state.show_help);
|
||||
|
||||
app.update(Msg::ShowHelp);
|
||||
assert!(!app.state.show_help);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_msg_sets_toast() {
|
||||
let mut app = test_app();
|
||||
app.update(Msg::Error(crate::message::AppError::DbBusy));
|
||||
assert!(app.state.error_toast.is_some());
|
||||
assert!(app.state.error_toast.as_ref().unwrap().contains("busy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resize_updates_terminal_size() {
|
||||
let mut app = test_app();
|
||||
app.update(Msg::Resize {
|
||||
width: 120,
|
||||
height: 40,
|
||||
});
|
||||
assert_eq!(app.state.terminal_size, (120, 40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stale_result_dropped() {
|
||||
let mut app = test_app();
|
||||
|
||||
// Submit two tasks for IssueList — second supersedes first.
|
||||
let gen1 = app
|
||||
.supervisor
|
||||
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
||||
.generation;
|
||||
let gen2 = app
|
||||
.supervisor
|
||||
.submit(TaskKey::LoadScreen(Screen::IssueList))
|
||||
.generation;
|
||||
|
||||
// Stale result with gen1 should be ignored.
|
||||
app.update(Msg::IssueListLoaded {
|
||||
generation: gen1,
|
||||
rows: vec![crate::message::IssueRow {
|
||||
key: crate::message::EntityKey::issue(1, 1),
|
||||
title: "stale".into(),
|
||||
state: "opened".into(),
|
||||
}],
|
||||
});
|
||||
assert!(app.state.issue_list.rows.is_empty());
|
||||
|
||||
// Current result with gen2 should be applied.
|
||||
app.update(Msg::IssueListLoaded {
|
||||
generation: gen2,
|
||||
rows: vec![crate::message::IssueRow {
|
||||
key: crate::message::EntityKey::issue(1, 2),
|
||||
title: "fresh".into(),
|
||||
state: "opened".into(),
|
||||
}],
|
||||
});
|
||||
assert_eq!(app.state.issue_list.rows.len(), 1);
|
||||
assert_eq!(app.state.issue_list.rows[0].title, "fresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_crash_context_records_events() {
|
||||
let mut app = test_app();
|
||||
app.update(Msg::Tick);
|
||||
app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
|
||||
// Should have recorded at least 2 events.
|
||||
assert!(app.crash_context.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_navigate_sets_loading_state() {
|
||||
let mut app = test_app();
|
||||
app.update(Msg::NavigateTo(Screen::IssueList));
|
||||
assert_eq!(
|
||||
*app.state.load_state.get(&Screen::IssueList),
|
||||
LoadState::Refreshing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_palette_opens_from_ctrl_p() {
|
||||
let mut app = test_app();
|
||||
let key = KeyEvent::new(KeyCode::Char('p')).with_modifiers(Modifiers::CTRL);
|
||||
app.update(Msg::RawEvent(Event::Key(key)));
|
||||
assert!(matches!(app.input_mode, InputMode::Palette));
|
||||
assert!(app.state.command_palette.query_focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_esc_closes_palette() {
|
||||
let mut app = test_app();
|
||||
app.input_mode = InputMode::Palette;
|
||||
|
||||
let key = KeyEvent::new(KeyCode::Escape);
|
||||
app.update(Msg::RawEvent(Event::Key(key)));
|
||||
|
||||
assert!(matches!(app.input_mode, InputMode::Normal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blur_text_input_msg() {
|
||||
let mut app = test_app();
|
||||
app.input_mode = InputMode::Text;
|
||||
app.state.search.query_focused = true;
|
||||
|
||||
app.update(Msg::BlurTextInput);
|
||||
|
||||
assert!(matches!(app.input_mode, InputMode::Normal));
|
||||
assert!(!app.state.has_text_focus());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_is_new() {
|
||||
let app = LoreApp::default();
|
||||
assert!(app.navigation.is_at(&Screen::Dashboard));
|
||||
assert!(matches!(app.input_mode, InputMode::Normal));
|
||||
}
|
||||
}
|
||||
|
||||
807
crates/lore-tui/src/commands.rs
Normal file
807
crates/lore-tui/src/commands.rs
Normal file
@@ -0,0 +1,807 @@
|
||||
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
|
||||
|
||||
//! Command registry — single source of truth for all TUI actions.
|
||||
//!
|
||||
//! Every keybinding, palette entry, help text, CLI equivalent, and
|
||||
//! status hint is generated from [`CommandRegistry`]. No hardcoded
|
||||
//! duplicate maps exist in view/state modules.
|
||||
//!
|
||||
//! Supports single-key and two-key sequences (g-prefix vim bindings).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ftui::{KeyCode, Modifiers};
|
||||
|
||||
use crate::message::{InputMode, Screen};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a key code + modifiers as a human-readable string.
|
||||
fn format_key(code: KeyCode, modifiers: Modifiers) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if modifiers.contains(Modifiers::CTRL) {
|
||||
parts.push("Ctrl");
|
||||
}
|
||||
if modifiers.contains(Modifiers::ALT) {
|
||||
parts.push("Alt");
|
||||
}
|
||||
if modifiers.contains(Modifiers::SHIFT) {
|
||||
parts.push("Shift");
|
||||
}
|
||||
let key_name = match code {
|
||||
KeyCode::Char(c) => c.to_string(),
|
||||
KeyCode::Enter => "Enter".to_string(),
|
||||
KeyCode::Escape => "Esc".to_string(),
|
||||
KeyCode::Tab => "Tab".to_string(),
|
||||
KeyCode::Backspace => "Backspace".to_string(),
|
||||
KeyCode::Delete => "Del".to_string(),
|
||||
KeyCode::Up => "Up".to_string(),
|
||||
KeyCode::Down => "Down".to_string(),
|
||||
KeyCode::Left => "Left".to_string(),
|
||||
KeyCode::Right => "Right".to_string(),
|
||||
KeyCode::Home => "Home".to_string(),
|
||||
KeyCode::End => "End".to_string(),
|
||||
KeyCode::PageUp => "PgUp".to_string(),
|
||||
KeyCode::PageDown => "PgDn".to_string(),
|
||||
KeyCode::F(n) => format!("F{n}"),
|
||||
_ => "?".to_string(),
|
||||
};
|
||||
parts.push(&key_name);
|
||||
// We need to own the joined string.
|
||||
let joined: String = parts.join("+");
|
||||
joined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KeyCombo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A keybinding: either a single key or a two-key sequence.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum KeyCombo {
|
||||
/// Single key press (e.g., `q`, `Esc`, `Ctrl+P`).
|
||||
Single { code: KeyCode, modifiers: Modifiers },
|
||||
/// Two-key sequence (e.g., `g` then `i` for go-to-issues).
|
||||
Sequence {
|
||||
first_code: KeyCode,
|
||||
first_modifiers: Modifiers,
|
||||
second_code: KeyCode,
|
||||
second_modifiers: Modifiers,
|
||||
},
|
||||
}
|
||||
|
||||
impl KeyCombo {
|
||||
/// Convenience: single key with no modifiers.
|
||||
#[must_use]
|
||||
pub const fn key(code: KeyCode) -> Self {
|
||||
Self::Single {
|
||||
code,
|
||||
modifiers: Modifiers::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: single key with Ctrl modifier.
|
||||
#[must_use]
|
||||
pub const fn ctrl(code: KeyCode) -> Self {
|
||||
Self::Single {
|
||||
code,
|
||||
modifiers: Modifiers::CTRL,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: g-prefix sequence (g + char).
|
||||
#[must_use]
|
||||
pub const fn g_then(c: char) -> Self {
|
||||
Self::Sequence {
|
||||
first_code: KeyCode::Char('g'),
|
||||
first_modifiers: Modifiers::NONE,
|
||||
second_code: KeyCode::Char(c),
|
||||
second_modifiers: Modifiers::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable display string for this key combo.
|
||||
#[must_use]
|
||||
pub fn display(&self) -> String {
|
||||
match self {
|
||||
Self::Single { code, modifiers } => format_key(*code, *modifiers),
|
||||
Self::Sequence {
|
||||
first_code,
|
||||
first_modifiers,
|
||||
second_code,
|
||||
second_modifiers,
|
||||
} => {
|
||||
let first = format_key(*first_code, *first_modifiers);
|
||||
let second = format_key(*second_code, *second_modifiers);
|
||||
format!("{first} {second}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this combo starts with the given key.
|
||||
#[must_use]
|
||||
pub fn starts_with(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
|
||||
match self {
|
||||
Self::Single {
|
||||
code: c,
|
||||
modifiers: m,
|
||||
} => c == code && m == modifiers,
|
||||
Self::Sequence {
|
||||
first_code,
|
||||
first_modifiers,
|
||||
..
|
||||
} => first_code == code && first_modifiers == modifiers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScreenFilter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Specifies which screens a command is available on.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ScreenFilter {
|
||||
/// Available on all screens.
|
||||
Global,
|
||||
/// Available only on specific screens.
|
||||
Only(Vec<Screen>),
|
||||
}
|
||||
|
||||
impl ScreenFilter {
|
||||
/// Whether the command is available on the given screen.
|
||||
#[must_use]
|
||||
pub fn matches(&self, screen: &Screen) -> bool {
|
||||
match self {
|
||||
Self::Global => true,
|
||||
Self::Only(screens) => screens.contains(screen),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CommandDef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Unique command identifier.
|
||||
pub type CommandId = &'static str;
|
||||
|
||||
/// A registered command with its keybinding, help text, and scope.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandDef {
|
||||
/// Unique identifier (e.g., "quit", "go_issues").
|
||||
pub id: CommandId,
|
||||
/// Human-readable label for palette and help overlay.
|
||||
pub label: &'static str,
|
||||
/// Keybinding (if any).
|
||||
pub keybinding: Option<KeyCombo>,
|
||||
/// Equivalent `lore` CLI command (for "Show CLI equivalent" feature).
|
||||
pub cli_equivalent: Option<&'static str>,
|
||||
/// Description for help overlay.
|
||||
pub help_text: &'static str,
|
||||
/// Short hint for status bar (e.g., "q:quit").
|
||||
pub status_hint: &'static str,
|
||||
/// Which screens this command is available on.
|
||||
pub available_in: ScreenFilter,
|
||||
/// Whether this command works in Text input mode.
|
||||
pub available_in_text_mode: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CommandRegistry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Single source of truth for all TUI commands.
|
||||
///
|
||||
/// Built once at startup via [`build_registry`]. Provides O(1) lookup
|
||||
/// by keybinding and per-screen filtering.
|
||||
pub struct CommandRegistry {
|
||||
commands: Vec<CommandDef>,
|
||||
/// Single-key -> command IDs that start with this key.
|
||||
by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>>,
|
||||
/// Full sequence -> command index (for two-key combos).
|
||||
by_sequence: HashMap<KeyCombo, usize>,
|
||||
}
|
||||
|
||||
impl CommandRegistry {
|
||||
/// Look up a command by a single key press on a given screen and input mode.
|
||||
///
|
||||
/// Returns `None` if no matching command is found. For sequence starters
|
||||
/// (like 'g'), returns `None` — use [`is_sequence_starter`] to detect
|
||||
/// that case.
|
||||
#[must_use]
|
||||
pub fn lookup_key(
|
||||
&self,
|
||||
code: &KeyCode,
|
||||
modifiers: &Modifiers,
|
||||
screen: &Screen,
|
||||
mode: &InputMode,
|
||||
) -> Option<&CommandDef> {
|
||||
let is_text = matches!(mode, InputMode::Text);
|
||||
let key = (*code, *modifiers);
|
||||
|
||||
let indices = self.by_single_key.get(&key)?;
|
||||
for &idx in indices {
|
||||
let cmd = &self.commands[idx];
|
||||
if !cmd.available_in.matches(screen) {
|
||||
continue;
|
||||
}
|
||||
if is_text && !cmd.available_in_text_mode {
|
||||
continue;
|
||||
}
|
||||
// Only match Single combos here, not sequence starters.
|
||||
if let Some(KeyCombo::Single { .. }) = &cmd.keybinding {
|
||||
return Some(cmd);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Complete a two-key sequence.
|
||||
///
|
||||
/// Called after the first key of a sequence is detected (e.g., after 'g').
|
||||
#[must_use]
|
||||
pub fn complete_sequence(
|
||||
&self,
|
||||
first_code: &KeyCode,
|
||||
first_modifiers: &Modifiers,
|
||||
second_code: &KeyCode,
|
||||
second_modifiers: &Modifiers,
|
||||
screen: &Screen,
|
||||
) -> Option<&CommandDef> {
|
||||
let combo = KeyCombo::Sequence {
|
||||
first_code: *first_code,
|
||||
first_modifiers: *first_modifiers,
|
||||
second_code: *second_code,
|
||||
second_modifiers: *second_modifiers,
|
||||
};
|
||||
let &idx = self.by_sequence.get(&combo)?;
|
||||
let cmd = &self.commands[idx];
|
||||
if cmd.available_in.matches(screen) {
|
||||
Some(cmd)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a key starts a multi-key sequence (e.g., 'g').
|
||||
#[must_use]
|
||||
pub fn is_sequence_starter(&self, code: &KeyCode, modifiers: &Modifiers) -> bool {
|
||||
self.by_sequence
|
||||
.keys()
|
||||
.any(|combo| combo.starts_with(code, modifiers))
|
||||
}
|
||||
|
||||
/// Commands available for the command palette on a given screen.
|
||||
///
|
||||
/// Returned sorted by label.
|
||||
#[must_use]
|
||||
pub fn palette_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
|
||||
let mut entries: Vec<&CommandDef> = self
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| c.available_in.matches(screen))
|
||||
.collect();
|
||||
entries.sort_by_key(|c| c.label);
|
||||
entries
|
||||
}
|
||||
|
||||
/// Commands for the help overlay on a given screen.
|
||||
#[must_use]
|
||||
pub fn help_entries(&self, screen: &Screen) -> Vec<&CommandDef> {
|
||||
self.commands
|
||||
.iter()
|
||||
.filter(|c| c.available_in.matches(screen))
|
||||
.filter(|c| c.keybinding.is_some())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Status bar hints for the current screen.
|
||||
#[must_use]
|
||||
pub fn status_hints(&self, screen: &Screen) -> Vec<&str> {
|
||||
self.commands
|
||||
.iter()
|
||||
.filter(|c| c.available_in.matches(screen))
|
||||
.filter(|c| !c.status_hint.is_empty())
|
||||
.map(|c| c.status_hint)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Total number of registered commands.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.commands.len()
|
||||
}
|
||||
|
||||
/// Whether the registry has no commands.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.commands.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// build_registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the command registry with all TUI commands.
|
||||
///
|
||||
/// This is the single source of truth — every keybinding, help text,
|
||||
/// and palette entry originates here.
|
||||
#[must_use]
|
||||
pub fn build_registry() -> CommandRegistry {
|
||||
let commands = vec![
|
||||
// --- Global commands ---
|
||||
CommandDef {
|
||||
id: "quit",
|
||||
label: "Quit",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('q'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Exit the TUI",
|
||||
status_hint: "q:quit",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_back",
|
||||
label: "Go Back",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Escape)),
|
||||
cli_equivalent: None,
|
||||
help_text: "Go back to previous screen",
|
||||
status_hint: "esc:back",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: true,
|
||||
},
|
||||
CommandDef {
|
||||
id: "show_help",
|
||||
label: "Help",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('?'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Show keybinding help overlay",
|
||||
status_hint: "?:help",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "command_palette",
|
||||
label: "Command Palette",
|
||||
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('p'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Open command palette",
|
||||
status_hint: "C-p:palette",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: true,
|
||||
},
|
||||
CommandDef {
|
||||
id: "open_in_browser",
|
||||
label: "Open in Browser",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('o'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Open current entity in browser",
|
||||
status_hint: "o:browser",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "show_cli",
|
||||
label: "Show CLI Equivalent",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('!'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Show equivalent lore CLI command",
|
||||
status_hint: "",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Navigation: g-prefix sequences ---
|
||||
CommandDef {
|
||||
id: "go_home",
|
||||
label: "Go to Dashboard",
|
||||
keybinding: Some(KeyCombo::g_then('h')),
|
||||
cli_equivalent: None,
|
||||
help_text: "Jump to dashboard",
|
||||
status_hint: "gh:home",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_issues",
|
||||
label: "Go to Issues",
|
||||
keybinding: Some(KeyCombo::g_then('i')),
|
||||
cli_equivalent: Some("lore issues"),
|
||||
help_text: "Jump to issue list",
|
||||
status_hint: "gi:issues",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_mrs",
|
||||
label: "Go to Merge Requests",
|
||||
keybinding: Some(KeyCombo::g_then('m')),
|
||||
cli_equivalent: Some("lore mrs"),
|
||||
help_text: "Jump to MR list",
|
||||
status_hint: "gm:mrs",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_search",
|
||||
label: "Go to Search",
|
||||
keybinding: Some(KeyCombo::g_then('/')),
|
||||
cli_equivalent: Some("lore search"),
|
||||
help_text: "Jump to search",
|
||||
status_hint: "g/:search",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_timeline",
|
||||
label: "Go to Timeline",
|
||||
keybinding: Some(KeyCombo::g_then('t')),
|
||||
cli_equivalent: Some("lore timeline"),
|
||||
help_text: "Jump to timeline",
|
||||
status_hint: "gt:timeline",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_who",
|
||||
label: "Go to Who",
|
||||
keybinding: Some(KeyCombo::g_then('w')),
|
||||
cli_equivalent: Some("lore who"),
|
||||
help_text: "Jump to people intelligence",
|
||||
status_hint: "gw:who",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "go_sync",
|
||||
label: "Go to Sync",
|
||||
keybinding: Some(KeyCombo::g_then('s')),
|
||||
cli_equivalent: Some("lore sync"),
|
||||
help_text: "Jump to sync status",
|
||||
status_hint: "gs:sync",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Vim-style jump list ---
|
||||
CommandDef {
|
||||
id: "jump_back",
|
||||
label: "Jump Back",
|
||||
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('o'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Jump backward through visited detail views",
|
||||
status_hint: "C-o:jump back",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "jump_forward",
|
||||
label: "Jump Forward",
|
||||
keybinding: Some(KeyCombo::ctrl(KeyCode::Char('i'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Jump forward through visited detail views",
|
||||
status_hint: "",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- List navigation ---
|
||||
CommandDef {
|
||||
id: "move_down",
|
||||
label: "Move Down",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('j'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Move cursor down",
|
||||
status_hint: "j:down",
|
||||
available_in: ScreenFilter::Only(vec![
|
||||
Screen::IssueList,
|
||||
Screen::MrList,
|
||||
Screen::Search,
|
||||
Screen::Timeline,
|
||||
]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "move_up",
|
||||
label: "Move Up",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('k'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Move cursor up",
|
||||
status_hint: "k:up",
|
||||
available_in: ScreenFilter::Only(vec![
|
||||
Screen::IssueList,
|
||||
Screen::MrList,
|
||||
Screen::Search,
|
||||
Screen::Timeline,
|
||||
]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
CommandDef {
|
||||
id: "select_item",
|
||||
label: "Select",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Enter)),
|
||||
cli_equivalent: None,
|
||||
help_text: "Open selected item",
|
||||
status_hint: "enter:open",
|
||||
available_in: ScreenFilter::Only(vec![
|
||||
Screen::IssueList,
|
||||
Screen::MrList,
|
||||
Screen::Search,
|
||||
]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Filter ---
|
||||
CommandDef {
|
||||
id: "focus_filter",
|
||||
label: "Filter",
|
||||
keybinding: Some(KeyCombo::key(KeyCode::Char('/'))),
|
||||
cli_equivalent: None,
|
||||
help_text: "Focus the filter input",
|
||||
status_hint: "/:filter",
|
||||
available_in: ScreenFilter::Only(vec![Screen::IssueList, Screen::MrList]),
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
// --- Scroll ---
|
||||
CommandDef {
|
||||
id: "scroll_to_top",
|
||||
label: "Scroll to Top",
|
||||
keybinding: Some(KeyCombo::g_then('g')),
|
||||
cli_equivalent: None,
|
||||
help_text: "Scroll to the top of the current view",
|
||||
status_hint: "",
|
||||
available_in: ScreenFilter::Global,
|
||||
available_in_text_mode: false,
|
||||
},
|
||||
];
|
||||
|
||||
build_from_defs(commands)
|
||||
}
|
||||
|
||||
/// Build index maps from a list of command definitions.
|
||||
fn build_from_defs(commands: Vec<CommandDef>) -> CommandRegistry {
|
||||
let mut by_single_key: HashMap<(KeyCode, Modifiers), Vec<usize>> = HashMap::new();
|
||||
let mut by_sequence: HashMap<KeyCombo, usize> = HashMap::new();
|
||||
|
||||
for (idx, cmd) in commands.iter().enumerate() {
|
||||
if let Some(combo) = &cmd.keybinding {
|
||||
match combo {
|
||||
KeyCombo::Single { code, modifiers } => {
|
||||
by_single_key
|
||||
.entry((*code, *modifiers))
|
||||
.or_default()
|
||||
.push(idx);
|
||||
}
|
||||
KeyCombo::Sequence { .. } => {
|
||||
by_sequence.insert(combo.clone(), idx);
|
||||
// Also index the first key so is_sequence_starter works via by_single_key.
|
||||
if let KeyCombo::Sequence {
|
||||
first_code,
|
||||
first_modifiers,
|
||||
..
|
||||
} = combo
|
||||
{
|
||||
by_single_key
|
||||
.entry((*first_code, *first_modifiers))
|
||||
.or_default()
|
||||
.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CommandRegistry {
|
||||
commands,
|
||||
by_single_key,
|
||||
by_sequence,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
|
||||
#[test]
|
||||
fn test_registry_builds_successfully() {
|
||||
let reg = build_registry();
|
||||
assert!(!reg.is_empty());
|
||||
assert!(reg.len() >= 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_lookup_quit() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('q'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Normal,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "quit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_lookup_quit_blocked_in_text_mode() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('q'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Text,
|
||||
);
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_esc_works_in_text_mode() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Escape,
|
||||
&Modifiers::NONE,
|
||||
&Screen::IssueList,
|
||||
&InputMode::Text,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "go_back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_registry_ctrl_p_works_in_text_mode() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('p'),
|
||||
&Modifiers::CTRL,
|
||||
&Screen::Search,
|
||||
&InputMode::Text,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "command_palette");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_g_is_sequence_starter() {
|
||||
let reg = build_registry();
|
||||
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
|
||||
assert!(!reg.is_sequence_starter(&KeyCode::Char('x'), &Modifiers::NONE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_sequence_gi() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.complete_sequence(
|
||||
&KeyCode::Char('g'),
|
||||
&Modifiers::NONE,
|
||||
&KeyCode::Char('i'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "go_issues");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_sequence_invalid_second_key() {
|
||||
let reg = build_registry();
|
||||
let cmd = reg.complete_sequence(
|
||||
&KeyCode::Char('g'),
|
||||
&Modifiers::NONE,
|
||||
&KeyCode::Char('x'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
);
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_screen_specific_command() {
|
||||
let reg = build_registry();
|
||||
// 'j' (move_down) should work on IssueList
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('j'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::IssueList,
|
||||
&InputMode::Normal,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "move_down");
|
||||
|
||||
// 'j' should NOT match on Dashboard (move_down is list-only).
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('j'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Normal,
|
||||
);
|
||||
assert!(cmd.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_palette_entries_sorted_by_label() {
|
||||
let reg = build_registry();
|
||||
let entries = reg.palette_entries(&Screen::Dashboard);
|
||||
let labels: Vec<&str> = entries.iter().map(|c| c.label).collect();
|
||||
let mut sorted = labels.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(labels, sorted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_entries_only_include_keybindings() {
|
||||
let reg = build_registry();
|
||||
let entries = reg.help_entries(&Screen::Dashboard);
|
||||
for entry in &entries {
|
||||
assert!(
|
||||
entry.keybinding.is_some(),
|
||||
"help entry without keybinding: {}",
|
||||
entry.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_hints_non_empty() {
|
||||
let reg = build_registry();
|
||||
let hints = reg.status_hints(&Screen::Dashboard);
|
||||
assert!(!hints.is_empty());
|
||||
// All returned hints should be non-empty strings.
|
||||
for hint in &hints {
|
||||
assert!(!hint.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_equivalents_populated() {
|
||||
let reg = build_registry();
|
||||
let with_cli: Vec<&CommandDef> = reg
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|c| c.cli_equivalent.is_some())
|
||||
.collect();
|
||||
assert!(
|
||||
with_cli.len() >= 5,
|
||||
"expected at least 5 commands with cli_equivalent, got {}",
|
||||
with_cli.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_go_prefix_timeout_detection() {
|
||||
let reg = build_registry();
|
||||
// Simulate GoPrefix mode entering: 'g' detected as sequence starter.
|
||||
assert!(reg.is_sequence_starter(&KeyCode::Char('g'), &Modifiers::NONE));
|
||||
|
||||
// Simulate InputMode::GoPrefix with timeout check.
|
||||
let started = Utc::now();
|
||||
let mode = InputMode::GoPrefix {
|
||||
started_at: started,
|
||||
};
|
||||
// In GoPrefix mode, normal lookup should still work for non-sequence keys.
|
||||
let cmd = reg.lookup_key(
|
||||
&KeyCode::Char('q'),
|
||||
&Modifiers::NONE,
|
||||
&Screen::Dashboard,
|
||||
&mode,
|
||||
);
|
||||
assert!(cmd.is_some());
|
||||
assert_eq!(cmd.unwrap().id, "quit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_commands_have_nonempty_help() {
|
||||
let reg = build_registry();
|
||||
for cmd in ®.commands {
|
||||
assert!(
|
||||
!cmd.help_text.is_empty(),
|
||||
"command {} has empty help_text",
|
||||
cmd.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
443
crates/lore-tui/src/crash_context.rs
Normal file
443
crates/lore-tui/src/crash_context.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
|
||||
|
||||
//! Ring buffer of recent app events for post-mortem crash diagnostics.
|
||||
//!
|
||||
//! The TUI pushes every key press, message dispatch, and state transition
|
||||
//! into [`CrashContext`]. On panic the installed hook dumps the last 2000
|
||||
//! events to `~/.local/share/lore/crash-<timestamp>.json` as NDJSON.
|
||||
//!
|
||||
//! Retention: only the 5 most recent crash files are kept.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, BufWriter, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Maximum number of events retained in the ring buffer.
|
||||
const MAX_EVENTS: usize = 2000;
|
||||
|
||||
/// Maximum number of crash files to keep on disk.
|
||||
const MAX_CRASH_FILES: usize = 5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CrashEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single event recorded for crash diagnostics.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum CrashEvent {
|
||||
/// A key was pressed.
|
||||
KeyPress {
|
||||
key: String,
|
||||
mode: String,
|
||||
screen: String,
|
||||
},
|
||||
/// A message was dispatched through update().
|
||||
MsgDispatched { msg_name: String, screen: String },
|
||||
/// Navigation changed screens.
|
||||
StateTransition { from: String, to: String },
|
||||
/// An error occurred.
|
||||
Error { message: String },
|
||||
/// Catch-all for ad-hoc diagnostic breadcrumbs.
|
||||
Custom { tag: String, detail: String },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CrashContext
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ring buffer of recent app events for panic diagnostics.
|
||||
///
|
||||
/// Holds at most [`MAX_EVENTS`] entries. When full, the oldest event
|
||||
/// is evicted on each push.
|
||||
pub struct CrashContext {
|
||||
events: VecDeque<CrashEvent>,
|
||||
}
|
||||
|
||||
impl CrashContext {
|
||||
/// Create an empty crash context with pre-allocated capacity.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
events: VecDeque::with_capacity(MAX_EVENTS),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an event. Evicts the oldest when the buffer is full.
|
||||
pub fn push(&mut self, event: CrashEvent) {
|
||||
if self.events.len() == MAX_EVENTS {
|
||||
self.events.pop_front();
|
||||
}
|
||||
self.events.push_back(event);
|
||||
}
|
||||
|
||||
/// Number of events currently stored.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
|
||||
/// Whether the buffer is empty.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
|
||||
/// Iterate over stored events (oldest first).
|
||||
pub fn iter(&self) -> impl Iterator<Item = &CrashEvent> {
|
||||
self.events.iter()
|
||||
}
|
||||
|
||||
/// Dump all events to a file as newline-delimited JSON.
|
||||
///
|
||||
/// Creates parent directories if they don't exist.
|
||||
/// Returns `Ok(())` on success, `Err` on I/O failure.
|
||||
pub fn dump_to_file(&self, path: &Path) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = std::fs::File::create(path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
for event in &self.events {
|
||||
match serde_json::to_string(event) {
|
||||
Ok(json) => {
|
||||
writeln!(writer, "{json}")?;
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback to debug format if serialization fails.
|
||||
writeln!(
|
||||
writer,
|
||||
"{{\"type\":\"SerializationError\",\"debug\":\"{event:?}\"}}"
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Default crash directory: `~/.local/share/lore/`.
|
||||
#[must_use]
|
||||
pub fn crash_dir() -> Option<PathBuf> {
|
||||
dirs::data_local_dir().map(|d| d.join("lore"))
|
||||
}
|
||||
|
||||
/// Generate a timestamped crash file path.
|
||||
#[must_use]
|
||||
pub fn crash_file_path() -> Option<PathBuf> {
|
||||
let dir = Self::crash_dir()?;
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.3f");
|
||||
Some(dir.join(format!("crash-{timestamp}.json")))
|
||||
}
|
||||
|
||||
/// Remove old crash files, keeping only the most recent [`MAX_CRASH_FILES`].
|
||||
///
|
||||
/// Best-effort: silently ignores I/O errors on individual deletions.
|
||||
pub fn prune_crash_files() {
|
||||
let Some(dir) = Self::crash_dir() else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut crash_files: Vec<PathBuf> = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort ascending by filename (timestamps sort lexicographically).
|
||||
crash_files.sort();
|
||||
|
||||
if crash_files.len() > MAX_CRASH_FILES {
|
||||
let to_remove = crash_files.len() - MAX_CRASH_FILES;
|
||||
for path in &crash_files[..to_remove] {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Install a panic hook that dumps the crash context to disk.
|
||||
///
|
||||
/// Captures the current events via a snapshot. The hook chains with
|
||||
/// the default panic handler so backtraces are still printed.
|
||||
pub fn install_panic_hook(ctx: &Self) {
|
||||
let snapshot: Vec<CrashEvent> = ctx.events.iter().cloned().collect();
|
||||
let prev_hook = std::panic::take_hook();
|
||||
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
// Best-effort dump — never panic inside the panic hook.
|
||||
if let Some(path) = Self::crash_file_path() {
|
||||
let mut dump = CrashContext::new();
|
||||
for event in &snapshot {
|
||||
dump.push(event.clone());
|
||||
}
|
||||
// Add the panic info itself as the final event.
|
||||
dump.push(CrashEvent::Error {
|
||||
message: format!("{info}"),
|
||||
});
|
||||
let _ = dump.dump_to_file(&path);
|
||||
}
|
||||
|
||||
// Chain to the previous hook (prints backtrace, etc.).
|
||||
prev_hook(info);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CrashContext {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::BufRead;
|
||||
|
||||
/// Helper: create a numbered Custom event.
|
||||
fn event(n: usize) -> CrashEvent {
|
||||
CrashEvent::Custom {
|
||||
tag: "test".into(),
|
||||
detail: format!("event-{n}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ring_buffer_evicts_oldest() {
|
||||
let mut ctx = CrashContext::new();
|
||||
for i in 0..2500 {
|
||||
ctx.push(event(i));
|
||||
}
|
||||
assert_eq!(ctx.len(), MAX_EVENTS);
|
||||
|
||||
// First retained event should be #500 (0..499 evicted).
|
||||
let first = ctx.iter().next().unwrap();
|
||||
match first {
|
||||
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-500"),
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
|
||||
// Last retained event should be #2499.
|
||||
let last = ctx.iter().last().unwrap();
|
||||
match last {
|
||||
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-2499"),
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_is_empty() {
|
||||
let ctx = CrashContext::new();
|
||||
assert!(ctx.is_empty());
|
||||
assert_eq!(ctx.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_increments_len() {
|
||||
let mut ctx = CrashContext::new();
|
||||
ctx.push(event(1));
|
||||
ctx.push(event(2));
|
||||
assert_eq!(ctx.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_does_not_evict_below_capacity() {
|
||||
let mut ctx = CrashContext::new();
|
||||
for i in 0..MAX_EVENTS {
|
||||
ctx.push(event(i));
|
||||
}
|
||||
assert_eq!(ctx.len(), MAX_EVENTS);
|
||||
|
||||
// First should still be event-0.
|
||||
match ctx.iter().next().unwrap() {
|
||||
CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-0"),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dump_to_file_writes_ndjson() {
|
||||
let mut ctx = CrashContext::new();
|
||||
ctx.push(CrashEvent::KeyPress {
|
||||
key: "j".into(),
|
||||
mode: "Normal".into(),
|
||||
screen: "Dashboard".into(),
|
||||
});
|
||||
ctx.push(CrashEvent::MsgDispatched {
|
||||
msg_name: "NavigateTo".into(),
|
||||
screen: "Dashboard".into(),
|
||||
});
|
||||
ctx.push(CrashEvent::StateTransition {
|
||||
from: "Dashboard".into(),
|
||||
to: "IssueList".into(),
|
||||
});
|
||||
ctx.push(CrashEvent::Error {
|
||||
message: "db busy".into(),
|
||||
});
|
||||
ctx.push(CrashEvent::Custom {
|
||||
tag: "test".into(),
|
||||
detail: "hello".into(),
|
||||
});
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test-crash.json");
|
||||
ctx.dump_to_file(&path).unwrap();
|
||||
|
||||
// Verify: each line is valid JSON, total lines == 5.
|
||||
let file = std::fs::File::open(&path).unwrap();
|
||||
let reader = io::BufReader::new(file);
|
||||
let lines: Vec<String> = reader.lines().map(Result::unwrap).collect();
|
||||
assert_eq!(lines.len(), 5);
|
||||
|
||||
// Each line must parse as JSON.
|
||||
for line in &lines {
|
||||
let val: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
assert!(val.get("type").is_some(), "missing 'type' field: {line}");
|
||||
}
|
||||
|
||||
// Spot check first line: KeyPress with correct fields.
|
||||
let first: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
|
||||
assert_eq!(first["type"], "KeyPress");
|
||||
assert_eq!(first["key"], "j");
|
||||
assert_eq!(first["mode"], "Normal");
|
||||
assert_eq!(first["screen"], "Dashboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dump_creates_parent_directories() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let nested = dir.path().join("a").join("b").join("c").join("crash.json");
|
||||
|
||||
let mut ctx = CrashContext::new();
|
||||
ctx.push(event(1));
|
||||
ctx.dump_to_file(&nested).unwrap();
|
||||
|
||||
assert!(nested.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dump_empty_context_creates_empty_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("empty.json");
|
||||
|
||||
let ctx = CrashContext::new();
|
||||
ctx.dump_to_file(&path).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(content.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prune_keeps_newest_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let crash_dir = dir.path();
|
||||
|
||||
// Create 8 crash files with ordered timestamps.
|
||||
let filenames: Vec<String> = (0..8)
|
||||
.map(|i| format!("crash-2026010{i}-120000.000.json"))
|
||||
.collect();
|
||||
for name in &filenames {
|
||||
std::fs::write(crash_dir.join(name), "{}").unwrap();
|
||||
}
|
||||
|
||||
// Prune, pointing at our temp dir.
|
||||
prune_crash_files_in(crash_dir);
|
||||
|
||||
let remaining: Vec<String> = std::fs::read_dir(crash_dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.starts_with("crash-") && n.ends_with(".json"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(remaining.len(), MAX_CRASH_FILES);
|
||||
// Oldest 3 should be gone.
|
||||
for name in filenames.iter().take(3) {
|
||||
assert!(!remaining.contains(name));
|
||||
}
|
||||
// Newest 5 should remain.
|
||||
for name in filenames.iter().skip(3) {
|
||||
assert!(remaining.contains(name));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_event_variants_serialize() {
|
||||
let events = vec![
|
||||
CrashEvent::KeyPress {
|
||||
key: "q".into(),
|
||||
mode: "Normal".into(),
|
||||
screen: "Dashboard".into(),
|
||||
},
|
||||
CrashEvent::MsgDispatched {
|
||||
msg_name: "Quit".into(),
|
||||
screen: "Dashboard".into(),
|
||||
},
|
||||
CrashEvent::StateTransition {
|
||||
from: "Dashboard".into(),
|
||||
to: "IssueList".into(),
|
||||
},
|
||||
CrashEvent::Error {
|
||||
message: "oops".into(),
|
||||
},
|
||||
CrashEvent::Custom {
|
||||
tag: "debug".into(),
|
||||
detail: "trace".into(),
|
||||
},
|
||||
];
|
||||
|
||||
for event in events {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.get("type").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_is_new() {
|
||||
let ctx = CrashContext::default();
|
||||
assert!(ctx.is_empty());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test helper: prune files in a specific directory (not the real path).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn prune_crash_files_in(dir: &Path) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut crash_files: Vec<PathBuf> = entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json"))
|
||||
})
|
||||
.collect();
|
||||
|
||||
crash_files.sort();
|
||||
|
||||
if crash_files.len() > MAX_CRASH_FILES {
|
||||
let to_remove = crash_files.len() - MAX_CRASH_FILES;
|
||||
for path in &crash_files[..to_remove] {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,14 @@ pub mod theme; // Flexoki theme: build_theme, state_color, label_style (bd-5ofk)
|
||||
|
||||
pub mod app; // LoreApp Model trait impl (Phase 0 proof: bd-2emv, full: bd-6pmy)
|
||||
|
||||
// Phase 1 modules.
|
||||
pub mod commands; // CommandRegistry: keybindings, help, palette (bd-38lb)
|
||||
pub mod crash_context; // CrashContext ring buffer + panic hook (bd-2fr7)
|
||||
pub mod navigation; // NavigationStack: back/forward/jump list (bd-1qpp)
|
||||
pub mod state; // AppState, LoadState, ScreenIntent, per-screen states (bd-1v9m)
|
||||
pub mod task_supervisor; // TaskSupervisor: dedup + cancel + generation IDs (bd-3le2)
|
||||
pub mod view; // View layer: render_screen + common widgets (bd-26f2)
|
||||
|
||||
/// Options controlling how the TUI launches.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchOptions {
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
//! - [`InputMode`] — controls key dispatch routing.
|
||||
|
||||
use std::fmt;
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use ftui::Event;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,7 +74,7 @@ impl fmt::Display for EntityKey {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Navigation targets within the TUI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum Screen {
|
||||
Dashboard,
|
||||
IssueList,
|
||||
@@ -174,7 +174,7 @@ pub enum InputMode {
|
||||
/// Command palette is open.
|
||||
Palette,
|
||||
/// "g" prefix pressed — waiting for second key (500ms timeout).
|
||||
GoPrefix { started_at: Instant },
|
||||
GoPrefix { started_at: DateTime<Utc> },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
339
crates/lore-tui/src/navigation.rs
Normal file
339
crates/lore-tui/src/navigation.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
|
||||
|
||||
//! Browser-like navigation stack with vim-style jump list.
|
||||
//!
|
||||
//! Supports back/forward (browser), jump back/forward (vim Ctrl+O/Ctrl+I),
|
||||
//! and breadcrumb generation. State is preserved when navigating away —
|
||||
//! screens are never cleared on pop.
|
||||
|
||||
use crate::message::Screen;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NavigationStack
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Browser-like navigation with back/forward stacks and a vim jump list.
|
||||
///
|
||||
/// The jump list only records "significant" hops — detail views and
|
||||
/// cross-references — skipping list/dashboard screens that users
|
||||
/// visit briefly during drilling.
|
||||
pub struct NavigationStack {
|
||||
back_stack: Vec<Screen>,
|
||||
current: Screen,
|
||||
forward_stack: Vec<Screen>,
|
||||
jump_list: Vec<Screen>,
|
||||
jump_index: usize,
|
||||
}
|
||||
|
||||
impl NavigationStack {
|
||||
/// Create a new stack starting at the Dashboard.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
back_stack: Vec::new(),
|
||||
current: Screen::Dashboard,
|
||||
forward_stack: Vec::new(),
|
||||
jump_list: Vec::new(),
|
||||
jump_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The currently displayed screen.
|
||||
#[must_use]
|
||||
pub fn current(&self) -> &Screen {
|
||||
&self.current
|
||||
}
|
||||
|
||||
/// Whether the current screen matches the given screen.
|
||||
#[must_use]
|
||||
pub fn is_at(&self, screen: &Screen) -> bool {
|
||||
&self.current == screen
|
||||
}
|
||||
|
||||
/// Navigate to a new screen.
|
||||
///
|
||||
/// Pushes current to back_stack, clears forward_stack (browser behavior),
|
||||
/// and records detail hops in the jump list.
|
||||
pub fn push(&mut self, screen: Screen) {
|
||||
let old = std::mem::replace(&mut self.current, screen);
|
||||
self.back_stack.push(old);
|
||||
self.forward_stack.clear();
|
||||
|
||||
// Record significant hops in jump list (vim behavior):
|
||||
// truncate any forward entries beyond jump_index, then append.
|
||||
if self.current.is_detail_or_entity() {
|
||||
self.jump_list.truncate(self.jump_index);
|
||||
self.jump_list.push(self.current.clone());
|
||||
self.jump_index = self.jump_list.len();
|
||||
}
|
||||
}
|
||||
|
||||
/// Go back to the previous screen.
|
||||
///
|
||||
/// Returns `None` at root (can't pop past the initial screen).
|
||||
pub fn pop(&mut self) -> Option<&Screen> {
|
||||
let prev = self.back_stack.pop()?;
|
||||
let old = std::mem::replace(&mut self.current, prev);
|
||||
self.forward_stack.push(old);
|
||||
Some(&self.current)
|
||||
}
|
||||
|
||||
/// Go forward (redo a pop).
|
||||
///
|
||||
/// Returns `None` if there's nothing to go forward to.
|
||||
pub fn go_forward(&mut self) -> Option<&Screen> {
|
||||
let next = self.forward_stack.pop()?;
|
||||
let old = std::mem::replace(&mut self.current, next);
|
||||
self.back_stack.push(old);
|
||||
Some(&self.current)
|
||||
}
|
||||
|
||||
/// Jump backward through the jump list (vim Ctrl+O).
|
||||
///
|
||||
/// Only visits detail/entity screens.
|
||||
pub fn jump_back(&mut self) -> Option<&Screen> {
|
||||
if self.jump_index == 0 {
|
||||
return None;
|
||||
}
|
||||
self.jump_index -= 1;
|
||||
self.jump_list.get(self.jump_index)
|
||||
}
|
||||
|
||||
/// Jump forward through the jump list (vim Ctrl+I).
|
||||
pub fn jump_forward(&mut self) -> Option<&Screen> {
|
||||
if self.jump_index >= self.jump_list.len() {
|
||||
return None;
|
||||
}
|
||||
let screen = self.jump_list.get(self.jump_index)?;
|
||||
self.jump_index += 1;
|
||||
Some(screen)
|
||||
}
|
||||
|
||||
/// Reset to a single screen, clearing all history.
|
||||
pub fn reset_to(&mut self, screen: Screen) {
|
||||
self.current = screen;
|
||||
self.back_stack.clear();
|
||||
self.forward_stack.clear();
|
||||
self.jump_list.clear();
|
||||
self.jump_index = 0;
|
||||
}
|
||||
|
||||
/// Breadcrumb labels for the current navigation path.
|
||||
///
|
||||
/// Returns the back stack labels plus the current screen label.
|
||||
#[must_use]
|
||||
pub fn breadcrumbs(&self) -> Vec<&str> {
|
||||
self.back_stack
|
||||
.iter()
|
||||
.chain(std::iter::once(&self.current))
|
||||
.map(Screen::label)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Navigation depth (1 = at root, 2 = one push deep, etc.).
|
||||
#[must_use]
|
||||
pub fn depth(&self) -> usize {
|
||||
self.back_stack.len() + 1
|
||||
}
|
||||
|
||||
/// Whether there's anything to go back to.
|
||||
#[must_use]
|
||||
pub fn can_go_back(&self) -> bool {
|
||||
!self.back_stack.is_empty()
|
||||
}
|
||||
|
||||
/// Whether there's anything to go forward to.
|
||||
#[must_use]
|
||||
pub fn can_go_forward(&self) -> bool {
|
||||
!self.forward_stack.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NavigationStack {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::message::EntityKey;
|
||||
|
||||
#[test]
|
||||
fn test_new_starts_at_dashboard() {
|
||||
let nav = NavigationStack::new();
|
||||
assert!(nav.is_at(&Screen::Dashboard));
|
||||
assert_eq!(nav.depth(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_pop_preserves_order() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList);
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
|
||||
|
||||
assert!(nav.is_at(&Screen::IssueDetail(EntityKey::issue(1, 42))));
|
||||
assert_eq!(nav.depth(), 3);
|
||||
|
||||
nav.pop();
|
||||
assert!(nav.is_at(&Screen::IssueList));
|
||||
|
||||
nav.pop();
|
||||
assert!(nav.is_at(&Screen::Dashboard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pop_at_root_returns_none() {
|
||||
let mut nav = NavigationStack::new();
|
||||
assert!(nav.pop().is_none());
|
||||
assert!(nav.is_at(&Screen::Dashboard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forward_stack_cleared_on_new_push() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList);
|
||||
nav.push(Screen::Search);
|
||||
nav.pop(); // back to IssueList, Search in forward
|
||||
assert!(nav.can_go_forward());
|
||||
|
||||
nav.push(Screen::Timeline); // new push clears forward
|
||||
assert!(!nav.can_go_forward());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_go_forward_restores() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList);
|
||||
nav.push(Screen::Search);
|
||||
nav.pop(); // back to IssueList
|
||||
|
||||
let screen = nav.go_forward();
|
||||
assert!(screen.is_some());
|
||||
assert!(nav.is_at(&Screen::Search));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_go_forward_returns_none_when_empty() {
|
||||
let mut nav = NavigationStack::new();
|
||||
assert!(nav.go_forward().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jump_list_skips_list_screens() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList); // not a detail — skip
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1))); // detail — record
|
||||
nav.push(Screen::MrList); // not a detail — skip
|
||||
nav.push(Screen::MrDetail(EntityKey::mr(1, 2))); // detail — record
|
||||
|
||||
assert_eq!(nav.jump_list.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jump_back_and_forward() {
|
||||
let mut nav = NavigationStack::new();
|
||||
let issue = Screen::IssueDetail(EntityKey::issue(1, 1));
|
||||
let mr = Screen::MrDetail(EntityKey::mr(1, 2));
|
||||
|
||||
nav.push(Screen::IssueList);
|
||||
nav.push(issue.clone());
|
||||
nav.push(Screen::MrList);
|
||||
nav.push(mr.clone());
|
||||
|
||||
// jump_index is at 2 (past the end of 2 items)
|
||||
let prev = nav.jump_back();
|
||||
assert_eq!(prev, Some(&mr));
|
||||
|
||||
let prev = nav.jump_back();
|
||||
assert_eq!(prev, Some(&issue));
|
||||
|
||||
// at beginning
|
||||
assert!(nav.jump_back().is_none());
|
||||
|
||||
// forward
|
||||
let next = nav.jump_forward();
|
||||
assert_eq!(next, Some(&issue));
|
||||
|
||||
let next = nav.jump_forward();
|
||||
assert_eq!(next, Some(&mr));
|
||||
|
||||
// at end
|
||||
assert!(nav.jump_forward().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jump_list_truncates_on_new_push() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 2)));
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 3)));
|
||||
|
||||
// jump back twice
|
||||
nav.jump_back();
|
||||
nav.jump_back();
|
||||
// jump_index = 1, pointing at issue 2
|
||||
|
||||
// new detail push truncates forward entries
|
||||
nav.push(Screen::MrDetail(EntityKey::mr(1, 99)));
|
||||
|
||||
// should have issue(1,1) and mr(1,99), not issue(1,2) or issue(1,3)
|
||||
assert_eq!(nav.jump_list.len(), 2);
|
||||
assert_eq!(nav.jump_list[1], Screen::MrDetail(EntityKey::mr(1, 99)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_clears_all_history() {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList);
|
||||
nav.push(Screen::Search);
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 1)));
|
||||
|
||||
nav.reset_to(Screen::Dashboard);
|
||||
|
||||
assert!(nav.is_at(&Screen::Dashboard));
|
||||
assert_eq!(nav.depth(), 1);
|
||||
assert!(!nav.can_go_back());
|
||||
assert!(!nav.can_go_forward());
|
||||
assert!(nav.jump_list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breadcrumbs_reflect_stack() {
|
||||
let mut nav = NavigationStack::new();
|
||||
assert_eq!(nav.breadcrumbs(), vec!["Dashboard"]);
|
||||
|
||||
nav.push(Screen::IssueList);
|
||||
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues"]);
|
||||
|
||||
nav.push(Screen::IssueDetail(EntityKey::issue(1, 42)));
|
||||
assert_eq!(nav.breadcrumbs(), vec!["Dashboard", "Issues", "Issue"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_is_new() {
|
||||
let nav = NavigationStack::default();
|
||||
assert!(nav.is_at(&Screen::Dashboard));
|
||||
assert_eq!(nav.depth(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_go_back_and_forward() {
|
||||
let mut nav = NavigationStack::new();
|
||||
assert!(!nav.can_go_back());
|
||||
assert!(!nav.can_go_forward());
|
||||
|
||||
nav.push(Screen::IssueList);
|
||||
assert!(nav.can_go_back());
|
||||
assert!(!nav.can_go_forward());
|
||||
|
||||
nav.pop();
|
||||
assert!(!nav.can_go_back());
|
||||
assert!(nav.can_go_forward());
|
||||
}
|
||||
}
|
||||
11
crates/lore-tui/src/state/command_palette.rs
Normal file
11
crates/lore-tui/src/state/command_palette.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Command palette state.
|
||||
|
||||
/// State for the command palette overlay.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CommandPaletteState {
|
||||
pub query: String,
|
||||
pub query_focused: bool,
|
||||
pub selected_index: usize,
|
||||
}
|
||||
10
crates/lore-tui/src/state/dashboard.rs
Normal file
10
crates/lore-tui/src/state/dashboard.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Dashboard screen state.
|
||||
|
||||
/// State for the dashboard summary screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DashboardState {
|
||||
pub issue_count: u64,
|
||||
pub mr_count: u64,
|
||||
}
|
||||
14
crates/lore-tui/src/state/issue_detail.rs
Normal file
14
crates/lore-tui/src/state/issue_detail.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Issue detail screen state.
|
||||
|
||||
use crate::message::{Discussion, EntityKey, IssueDetail};
|
||||
|
||||
/// State for the issue detail screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct IssueDetailState {
|
||||
pub key: Option<EntityKey>,
|
||||
pub detail: Option<IssueDetail>,
|
||||
pub discussions: Vec<Discussion>,
|
||||
pub scroll_offset: u16,
|
||||
}
|
||||
14
crates/lore-tui/src/state/issue_list.rs
Normal file
14
crates/lore-tui/src/state/issue_list.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Issue list screen state.
|
||||
|
||||
use crate::message::IssueRow;
|
||||
|
||||
/// State for the issue list screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct IssueListState {
|
||||
pub rows: Vec<IssueRow>,
|
||||
pub filter: String,
|
||||
pub filter_focused: bool,
|
||||
pub selected_index: usize,
|
||||
}
|
||||
335
crates/lore-tui/src/state/mod.rs
Normal file
335
crates/lore-tui/src/state/mod.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
#![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 command_palette;
|
||||
pub mod dashboard;
|
||||
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 who;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::message::Screen;
|
||||
|
||||
// Re-export screen states for convenience.
|
||||
pub use command_palette::CommandPaletteState;
|
||||
pub use dashboard::DashboardState;
|
||||
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 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>,
|
||||
}
|
||||
|
||||
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) {
|
||||
if state == LoadState::Idle {
|
||||
self.map.remove(&screen);
|
||||
} else {
|
||||
self.map.insert(screen, state);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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 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
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
14
crates/lore-tui/src/state/mr_detail.rs
Normal file
14
crates/lore-tui/src/state/mr_detail.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Merge request detail screen state.
|
||||
|
||||
use crate::message::{Discussion, EntityKey, MrDetail};
|
||||
|
||||
/// State for the MR detail screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MrDetailState {
|
||||
pub key: Option<EntityKey>,
|
||||
pub detail: Option<MrDetail>,
|
||||
pub discussions: Vec<Discussion>,
|
||||
pub scroll_offset: u16,
|
||||
}
|
||||
14
crates/lore-tui/src/state/mr_list.rs
Normal file
14
crates/lore-tui/src/state/mr_list.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Merge request list screen state.
|
||||
|
||||
use crate::message::MrRow;
|
||||
|
||||
/// State for the MR list screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct MrListState {
|
||||
pub rows: Vec<MrRow>,
|
||||
pub filter: String,
|
||||
pub filter_focused: bool,
|
||||
pub selected_index: usize,
|
||||
}
|
||||
14
crates/lore-tui/src/state/search.rs
Normal file
14
crates/lore-tui/src/state/search.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Search screen state.
|
||||
|
||||
use crate::message::SearchResult;
|
||||
|
||||
/// State for the search screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SearchState {
|
||||
pub query: String,
|
||||
pub query_focused: bool,
|
||||
pub results: Vec<SearchResult>,
|
||||
pub selected_index: usize,
|
||||
}
|
||||
15
crates/lore-tui/src/state/sync.rs
Normal file
15
crates/lore-tui/src/state/sync.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Sync screen state.
|
||||
|
||||
/// State for the sync progress/summary screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SyncState {
|
||||
pub stage: String,
|
||||
pub current: u64,
|
||||
pub total: u64,
|
||||
pub log_lines: Vec<String>,
|
||||
pub completed: bool,
|
||||
pub elapsed_ms: Option<u64>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
12
crates/lore-tui/src/state/timeline.rs
Normal file
12
crates/lore-tui/src/state/timeline.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Timeline screen state.
|
||||
|
||||
use crate::message::TimelineEvent;
|
||||
|
||||
/// State for the timeline screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TimelineState {
|
||||
pub events: Vec<TimelineEvent>,
|
||||
pub scroll_offset: u16,
|
||||
}
|
||||
12
crates/lore-tui/src/state/who.rs
Normal file
12
crates/lore-tui/src/state/who.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
//! Who (people intelligence) screen state.
|
||||
|
||||
use crate::message::WhoResult;
|
||||
|
||||
/// State for the who/people screen.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WhoState {
|
||||
pub result: Option<WhoResult>,
|
||||
pub scroll_offset: u16,
|
||||
}
|
||||
380
crates/lore-tui/src/task_supervisor.rs
Normal file
380
crates/lore-tui/src/task_supervisor.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy
|
||||
|
||||
//! Centralized background task management with dedup and cancellation.
|
||||
//!
|
||||
//! All background work (DB queries, sync, search) flows through
|
||||
//! [`TaskSupervisor`]. Submitting a task with a key that already has an
|
||||
//! active handle cancels the previous task via its [`CancelToken`] and
|
||||
//! bumps the generation counter.
|
||||
//!
|
||||
//! Generation IDs enable stale-result detection: when an async result
|
||||
//! arrives, [`is_current`] checks whether the result's generation
|
||||
//! matches the latest submission for that key.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use crate::message::Screen;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Deduplication key for background tasks.
|
||||
///
|
||||
/// Two tasks with the same key cannot run concurrently — submitting a
|
||||
/// new task with an existing key cancels the previous one.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum TaskKey {
|
||||
/// Load data for a specific screen.
|
||||
LoadScreen(Screen),
|
||||
/// Global search query.
|
||||
Search,
|
||||
/// Sync stream (only one at a time).
|
||||
SyncStream,
|
||||
/// Re-query after filter change on a specific screen.
|
||||
FilterRequery(Screen),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskPriority
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Priority levels for task scheduling.
|
||||
///
|
||||
/// Lower numeric value = higher priority.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum TaskPriority {
|
||||
/// User-initiated input (highest priority).
|
||||
Input = 0,
|
||||
/// Navigation-triggered data load.
|
||||
Navigation = 1,
|
||||
/// Background refresh / prefetch (lowest priority).
|
||||
Background = 2,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CancelToken
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Thread-safe cooperative cancellation flag.
|
||||
///
|
||||
/// Background tasks poll [`is_cancelled`] periodically and exit early
|
||||
/// when it returns `true`.
|
||||
#[derive(Debug)]
|
||||
pub struct CancelToken {
|
||||
cancelled: AtomicBool,
|
||||
}
|
||||
|
||||
impl CancelToken {
|
||||
/// Create a new, non-cancelled token.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cancelled: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal cancellation.
|
||||
pub fn cancel(&self) {
|
||||
self.cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Check whether cancellation has been requested.
|
||||
#[must_use]
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CancelToken {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InterruptHandle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Opaque handle for interrupting a rusqlite operation.
|
||||
///
|
||||
/// Wraps the rusqlite `InterruptHandle` so the supervisor can cancel
|
||||
/// long-running queries. This is only set for tasks that lease a reader
|
||||
/// connection from [`DbManager`](crate::db::DbManager).
|
||||
pub struct InterruptHandle {
|
||||
handle: rusqlite::InterruptHandle,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for InterruptHandle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("InterruptHandle").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl InterruptHandle {
|
||||
/// Wrap a rusqlite interrupt handle.
|
||||
#[must_use]
|
||||
pub fn new(handle: rusqlite::InterruptHandle) -> Self {
|
||||
Self { handle }
|
||||
}
|
||||
|
||||
/// Interrupt the associated SQLite operation.
|
||||
pub fn interrupt(&self) {
|
||||
self.handle.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskHandle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle returned when a task is submitted.
|
||||
///
|
||||
/// Callers use this to pass the generation ID into async work so
|
||||
/// results can be tagged and checked for staleness.
|
||||
#[derive(Debug)]
|
||||
pub struct TaskHandle {
|
||||
/// Dedup key for this task.
|
||||
pub key: TaskKey,
|
||||
/// Monotonically increasing generation for stale detection.
|
||||
pub generation: u64,
|
||||
/// Cooperative cancellation token (shared with the supervisor).
|
||||
pub cancel: Arc<CancelToken>,
|
||||
/// Optional SQLite interrupt handle for long queries.
|
||||
pub interrupt: Option<InterruptHandle>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskSupervisor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Manages background tasks with deduplication and cancellation.
|
||||
///
|
||||
/// Only one task per [`TaskKey`] can be active. Submitting a new task
|
||||
/// with an existing key cancels the previous one (via its cancel token
|
||||
/// and optional interrupt handle) before registering the new handle.
|
||||
pub struct TaskSupervisor {
|
||||
active: HashMap<TaskKey, TaskHandle>,
|
||||
next_generation: AtomicU64,
|
||||
}
|
||||
|
||||
impl TaskSupervisor {
|
||||
/// Create a new supervisor with no active tasks.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
active: HashMap::new(),
|
||||
next_generation: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit a new task, cancelling any existing task with the same key.
|
||||
///
|
||||
/// Returns a [`TaskHandle`] with a fresh generation ID and a shared
|
||||
/// cancel token. The caller clones the `Arc<CancelToken>` and passes
|
||||
/// it into the async work.
|
||||
pub fn submit(&mut self, key: TaskKey) -> &TaskHandle {
|
||||
// Cancel existing task with this key, if any.
|
||||
if let Some(old) = self.active.remove(&key) {
|
||||
old.cancel.cancel();
|
||||
if let Some(interrupt) = &old.interrupt {
|
||||
interrupt.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||
let cancel = Arc::new(CancelToken::new());
|
||||
|
||||
let handle = TaskHandle {
|
||||
key: key.clone(),
|
||||
generation,
|
||||
cancel,
|
||||
interrupt: None,
|
||||
};
|
||||
|
||||
self.active.insert(key.clone(), handle);
|
||||
self.active.get(&key).expect("just inserted")
|
||||
}
|
||||
|
||||
/// Check whether a generation is current for a given key.
|
||||
///
|
||||
/// Returns `true` only if the key has an active handle with the
|
||||
/// specified generation.
|
||||
#[must_use]
|
||||
pub fn is_current(&self, key: &TaskKey, generation: u64) -> bool {
|
||||
self.active
|
||||
.get(key)
|
||||
.is_some_and(|h| h.generation == generation)
|
||||
}
|
||||
|
||||
/// Mark a task as complete, removing its handle.
|
||||
///
|
||||
/// Only removes the handle if the generation matches the active one.
|
||||
/// This prevents a late-arriving completion from removing a newer
|
||||
/// task's handle.
|
||||
pub fn complete(&mut self, key: &TaskKey, generation: u64) {
|
||||
if self.is_current(key, generation) {
|
||||
self.active.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all active tasks.
|
||||
///
|
||||
/// Used during shutdown to ensure background work stops promptly.
|
||||
pub fn cancel_all(&mut self) {
|
||||
for (_, handle) in self.active.drain() {
|
||||
handle.cancel.cancel();
|
||||
if let Some(interrupt) = &handle.interrupt {
|
||||
interrupt.interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of currently active tasks.
|
||||
#[must_use]
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.active.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskSupervisor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_submit_cancels_previous() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
let gen1 = sup.submit(TaskKey::Search).generation;
|
||||
let cancel1 = sup.active.get(&TaskKey::Search).unwrap().cancel.clone();
|
||||
|
||||
let gen2 = sup.submit(TaskKey::Search).generation;
|
||||
|
||||
// First task's token should be cancelled.
|
||||
assert!(cancel1.is_cancelled());
|
||||
// Second task should have a different (higher) generation.
|
||||
assert!(gen2 > gen1);
|
||||
// Only one active task for this key.
|
||||
assert_eq!(sup.active_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_current_after_supersede() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
let gen1 = sup.submit(TaskKey::Search).generation;
|
||||
let gen2 = sup.submit(TaskKey::Search).generation;
|
||||
|
||||
assert!(!sup.is_current(&TaskKey::Search, gen1));
|
||||
assert!(sup.is_current(&TaskKey::Search, gen2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_removes_handle() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
let generation = sup.submit(TaskKey::Search).generation;
|
||||
|
||||
assert_eq!(sup.active_count(), 1);
|
||||
sup.complete(&TaskKey::Search, generation);
|
||||
assert_eq!(sup.active_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complete_ignores_stale() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
let gen1 = sup.submit(TaskKey::Search).generation;
|
||||
let gen2 = sup.submit(TaskKey::Search).generation;
|
||||
|
||||
// Completing with old generation should NOT remove the newer handle.
|
||||
sup.complete(&TaskKey::Search, gen1);
|
||||
assert_eq!(sup.active_count(), 1);
|
||||
assert!(sup.is_current(&TaskKey::Search, gen2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generation_monotonic() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
let g1 = sup.submit(TaskKey::Search).generation;
|
||||
let g2 = sup.submit(TaskKey::SyncStream).generation;
|
||||
let g3 = sup.submit(TaskKey::Search).generation;
|
||||
|
||||
assert!(g1 < g2);
|
||||
assert!(g2 < g3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_keys_coexist() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
sup.submit(TaskKey::Search);
|
||||
sup.submit(TaskKey::SyncStream);
|
||||
sup.submit(TaskKey::LoadScreen(Screen::Dashboard));
|
||||
|
||||
assert_eq!(sup.active_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_all() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
let cancel_search = {
|
||||
sup.submit(TaskKey::Search);
|
||||
sup.active.get(&TaskKey::Search).unwrap().cancel.clone()
|
||||
};
|
||||
let cancel_sync = {
|
||||
sup.submit(TaskKey::SyncStream);
|
||||
sup.active.get(&TaskKey::SyncStream).unwrap().cancel.clone()
|
||||
};
|
||||
|
||||
sup.cancel_all();
|
||||
|
||||
assert!(cancel_search.is_cancelled());
|
||||
assert!(cancel_sync.is_cancelled());
|
||||
assert_eq!(sup.active_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_token_default_is_not_cancelled() {
|
||||
let token = CancelToken::new();
|
||||
assert!(!token.is_cancelled());
|
||||
token.cancel();
|
||||
assert!(token.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_token_is_send_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<CancelToken>();
|
||||
assert_send_sync::<Arc<CancelToken>>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_supervisor_default() {
|
||||
let sup = TaskSupervisor::default();
|
||||
assert_eq!(sup.active_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_requery_key_distinct_per_screen() {
|
||||
let mut sup = TaskSupervisor::new();
|
||||
|
||||
sup.submit(TaskKey::FilterRequery(Screen::IssueList));
|
||||
sup.submit(TaskKey::FilterRequery(Screen::MrList));
|
||||
|
||||
assert_eq!(sup.active_count(), 2);
|
||||
}
|
||||
}
|
||||
816
crates/lore-tui/src/view/common/mod.rs
Normal file
816
crates/lore-tui/src/view/common/mod.rs
Normal file
@@ -0,0 +1,816 @@
|
||||
#![allow(dead_code)] // Phase 1: consumed by screen views in Phase 2+
|
||||
|
||||
//! Common widgets shared across all TUI screens.
|
||||
//!
|
||||
//! These are pure rendering functions — they write directly into the
|
||||
//! [`Frame`] buffer using ftui's `Draw` trait. No state mutation,
|
||||
//! no side effects.
|
||||
//!
|
||||
//! - [`render_breadcrumb`] — navigation trail ("Dashboard > Issues > #42")
|
||||
//! - [`render_status_bar`] — bottom bar with key hints and mode indicator
|
||||
//! - [`render_loading`] — full-screen spinner or subtle refresh indicator
|
||||
//! - [`render_error_toast`] — floating error message at bottom-right
|
||||
//! - [`render_help_overlay`] — centered modal listing keybindings
|
||||
|
||||
use ftui::core::geometry::Rect;
|
||||
use ftui::render::cell::{Cell, PackedRgba};
|
||||
use ftui::render::drawing::Draw;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::commands::CommandRegistry;
|
||||
use crate::message::{InputMode, Screen};
|
||||
use crate::navigation::NavigationStack;
|
||||
use crate::state::LoadState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spinner frames
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Braille spinner frames for loading animation.
|
||||
const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
|
||||
/// Select spinner frame from tick count.
|
||||
#[must_use]
|
||||
fn spinner_char(tick: u64) -> char {
|
||||
SPINNER_FRAMES[(tick as usize) % SPINNER_FRAMES.len()]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render_breadcrumb
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the navigation breadcrumb trail.
|
||||
///
|
||||
/// Shows "Dashboard > Issues > Issue" with " > " separators. When the
|
||||
/// trail exceeds the available width, entries are truncated from the left
|
||||
/// with a leading "...".
|
||||
pub fn render_breadcrumb(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
nav: &NavigationStack,
|
||||
text_color: PackedRgba,
|
||||
muted_color: PackedRgba,
|
||||
) {
|
||||
if area.height == 0 || area.width < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let crumbs = nav.breadcrumbs();
|
||||
let separator = " > ";
|
||||
|
||||
// Build the full breadcrumb string and calculate width.
|
||||
let full: String = crumbs.join(separator);
|
||||
let max_width = area.width as usize;
|
||||
|
||||
let display = if full.len() <= max_width {
|
||||
full
|
||||
} else {
|
||||
// Truncate from the left: show "... > last_crumbs"
|
||||
truncate_breadcrumb_left(&crumbs, separator, max_width)
|
||||
};
|
||||
|
||||
let base = Cell {
|
||||
fg: text_color,
|
||||
..Cell::default()
|
||||
};
|
||||
let muted = Cell {
|
||||
fg: muted_color,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
// Render each segment with separators in muted color.
|
||||
let mut x = area.x;
|
||||
let max_x = area.x.saturating_add(area.width);
|
||||
|
||||
if let Some(rest) = display.strip_prefix("...") {
|
||||
// Render ellipsis in muted, then the rest
|
||||
x = frame.print_text_clipped(x, area.y, "...", muted, max_x);
|
||||
if !rest.is_empty() {
|
||||
render_crumb_segments(frame, x, area.y, rest, separator, base, muted, max_x);
|
||||
}
|
||||
} else {
|
||||
render_crumb_segments(frame, x, area.y, &display, separator, base, muted, max_x);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render breadcrumb text with separators in muted color.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_crumb_segments(
|
||||
frame: &mut Frame<'_>,
|
||||
start_x: u16,
|
||||
y: u16,
|
||||
text: &str,
|
||||
separator: &str,
|
||||
base: Cell,
|
||||
muted: Cell,
|
||||
max_x: u16,
|
||||
) {
|
||||
let mut x = start_x;
|
||||
let parts: Vec<&str> = text.split(separator).collect();
|
||||
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
x = frame.print_text_clipped(x, y, separator, muted, max_x);
|
||||
}
|
||||
x = frame.print_text_clipped(x, y, part, base, max_x);
|
||||
if x >= max_x {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate breadcrumb from the left to fit within max_width.
|
||||
fn truncate_breadcrumb_left(crumbs: &[&str], separator: &str, max_width: usize) -> String {
|
||||
let ellipsis = "...";
|
||||
|
||||
// Try showing progressively fewer crumbs from the right.
|
||||
for skip in 1..crumbs.len() {
|
||||
let tail = &crumbs[skip..];
|
||||
let tail_str: String = tail.join(separator);
|
||||
let candidate = format!("{ellipsis}{separator}{tail_str}");
|
||||
if candidate.len() <= max_width {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: just the current screen truncated.
|
||||
let last = crumbs.last().unwrap_or(&"");
|
||||
if last.len() + ellipsis.len() <= max_width {
|
||||
return format!("{ellipsis}{last}");
|
||||
}
|
||||
|
||||
// Truly tiny terminal: just ellipsis.
|
||||
ellipsis.to_string()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render_status_bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the bottom status bar with key hints and mode indicator.
|
||||
///
|
||||
/// Layout: `[mode] ─── [key hints]`
|
||||
///
|
||||
/// Key hints are sourced from the [`CommandRegistry`] filtered to the
|
||||
/// current screen, showing only the most important bindings.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_status_bar(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
registry: &CommandRegistry,
|
||||
screen: &Screen,
|
||||
mode: &InputMode,
|
||||
bar_bg: PackedRgba,
|
||||
text_color: PackedRgba,
|
||||
accent_color: PackedRgba,
|
||||
) {
|
||||
if area.height == 0 || area.width < 5 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill the bar background.
|
||||
let bg_cell = Cell {
|
||||
bg: bar_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.draw_rect_filled(area, bg_cell);
|
||||
|
||||
let mode_label = match mode {
|
||||
InputMode::Normal => "NORMAL",
|
||||
InputMode::Text => "INPUT",
|
||||
InputMode::Palette => "PALETTE",
|
||||
InputMode::GoPrefix { .. } => "g...",
|
||||
};
|
||||
|
||||
// Left side: mode indicator.
|
||||
let mode_cell = Cell {
|
||||
fg: accent_color,
|
||||
bg: bar_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
let mut x = frame.print_text_clipped(
|
||||
area.x.saturating_add(1),
|
||||
area.y,
|
||||
mode_label,
|
||||
mode_cell,
|
||||
area.right(),
|
||||
);
|
||||
|
||||
// Spacer.
|
||||
x = x.saturating_add(2);
|
||||
|
||||
// Right side: key hints from registry (formatted as "key:action").
|
||||
let hints = registry.status_hints(screen);
|
||||
let hint_cell = Cell {
|
||||
fg: text_color,
|
||||
bg: bar_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
let key_cell = Cell {
|
||||
fg: accent_color,
|
||||
bg: bar_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
for hint in &hints {
|
||||
if x >= area.right().saturating_sub(1) {
|
||||
break;
|
||||
}
|
||||
// Split "q:quit" into key part and description part.
|
||||
if let Some((key_part, desc_part)) = hint.split_once(':') {
|
||||
x = frame.print_text_clipped(x, area.y, key_part, key_cell, area.right());
|
||||
x = frame.print_text_clipped(x, area.y, ":", hint_cell, area.right());
|
||||
x = frame.print_text_clipped(x, area.y, desc_part, hint_cell, area.right());
|
||||
} else {
|
||||
x = frame.print_text_clipped(x, area.y, hint, hint_cell, area.right());
|
||||
}
|
||||
x = x.saturating_add(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render_loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a loading indicator.
|
||||
///
|
||||
/// - `LoadingInitial`: centered full-screen spinner with "Loading..."
|
||||
/// - `Refreshing`: subtle spinner in top-right corner
|
||||
/// - Other states: no-op
|
||||
pub fn render_loading(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
load_state: &LoadState,
|
||||
text_color: PackedRgba,
|
||||
muted_color: PackedRgba,
|
||||
tick: u64,
|
||||
) {
|
||||
match load_state {
|
||||
LoadState::LoadingInitial => {
|
||||
render_centered_spinner(frame, area, "Loading...", text_color, tick);
|
||||
}
|
||||
LoadState::Refreshing => {
|
||||
render_corner_spinner(frame, area, muted_color, tick);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a centered spinner with message.
|
||||
fn render_centered_spinner(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
message: &str,
|
||||
color: PackedRgba,
|
||||
tick: u64,
|
||||
) {
|
||||
if area.height == 0 || area.width < 5 {
|
||||
return;
|
||||
}
|
||||
|
||||
let spinner = spinner_char(tick);
|
||||
let text = format!("{spinner} {message}");
|
||||
let text_len = text.len() as u16;
|
||||
|
||||
// Center horizontally and vertically.
|
||||
let x = area
|
||||
.x
|
||||
.saturating_add(area.width.saturating_sub(text_len) / 2);
|
||||
let y = area.y.saturating_add(area.height / 2);
|
||||
|
||||
let cell = Cell {
|
||||
fg: color,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(x, y, &text, cell, area.right());
|
||||
}
|
||||
|
||||
/// Render a subtle spinner in the top-right corner.
|
||||
fn render_corner_spinner(frame: &mut Frame<'_>, area: Rect, color: PackedRgba, tick: u64) {
|
||||
if area.width < 2 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let spinner = spinner_char(tick);
|
||||
let x = area.right().saturating_sub(2);
|
||||
let y = area.y;
|
||||
|
||||
let cell = Cell {
|
||||
fg: color,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(x, y, &spinner.to_string(), cell, area.right());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render_error_toast
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a floating error toast at the bottom-right of the area.
|
||||
///
|
||||
/// The toast has a colored background and truncates long messages.
|
||||
pub fn render_error_toast(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
msg: &str,
|
||||
error_bg: PackedRgba,
|
||||
error_fg: PackedRgba,
|
||||
) {
|
||||
if area.height < 3 || area.width < 10 || msg.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toast dimensions: message + padding, max 60 chars or half screen.
|
||||
let max_toast_width = (area.width / 2).clamp(20, 60);
|
||||
let toast_text = if msg.len() as u16 > max_toast_width.saturating_sub(4) {
|
||||
let trunc_len = max_toast_width.saturating_sub(7) as usize;
|
||||
format!(" {}... ", &msg[..trunc_len.min(msg.len())])
|
||||
} else {
|
||||
format!(" {msg} ")
|
||||
};
|
||||
let toast_width = toast_text.len() as u16;
|
||||
let toast_height: u16 = 1;
|
||||
|
||||
// Position: bottom-right with 1-cell margin.
|
||||
let x = area.right().saturating_sub(toast_width + 1);
|
||||
let y = area.bottom().saturating_sub(toast_height + 1);
|
||||
|
||||
let toast_rect = Rect::new(x, y, toast_width, toast_height);
|
||||
|
||||
// Fill background.
|
||||
let bg_cell = Cell {
|
||||
bg: error_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.draw_rect_filled(toast_rect, bg_cell);
|
||||
|
||||
// Render text.
|
||||
let text_cell = Cell {
|
||||
fg: error_fg,
|
||||
bg: error_bg,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(x, y, &toast_text, text_cell, area.right());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render_help_overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a centered help overlay listing keybindings for the current screen.
|
||||
///
|
||||
/// The overlay is a bordered modal that lists all commands from the
|
||||
/// registry that are available on the current screen.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_help_overlay(
|
||||
frame: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
registry: &CommandRegistry,
|
||||
screen: &Screen,
|
||||
border_color: PackedRgba,
|
||||
text_color: PackedRgba,
|
||||
muted_color: PackedRgba,
|
||||
scroll_offset: usize,
|
||||
) {
|
||||
if area.height < 5 || area.width < 20 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Overlay dimensions: 60% of screen, capped.
|
||||
let overlay_width = (area.width * 3 / 5).clamp(30, 70);
|
||||
let overlay_height = (area.height * 3 / 5).clamp(8, 30);
|
||||
|
||||
let overlay_x = area.x + (area.width.saturating_sub(overlay_width)) / 2;
|
||||
let overlay_y = area.y + (area.height.saturating_sub(overlay_height)) / 2;
|
||||
let overlay_rect = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
|
||||
|
||||
// Draw border.
|
||||
let border_cell = Cell {
|
||||
fg: border_color,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.draw_border(
|
||||
overlay_rect,
|
||||
ftui::render::drawing::BorderChars::ROUNDED,
|
||||
border_cell,
|
||||
);
|
||||
|
||||
// Title.
|
||||
let title = " Help (? to close) ";
|
||||
let title_x = overlay_x + (overlay_width.saturating_sub(title.len() as u16)) / 2;
|
||||
let title_cell = Cell {
|
||||
fg: border_color,
|
||||
..Cell::default()
|
||||
};
|
||||
frame.print_text_clipped(title_x, overlay_y, title, title_cell, overlay_rect.right());
|
||||
|
||||
// Inner content area (inside border).
|
||||
let inner = Rect::new(
|
||||
overlay_x + 2,
|
||||
overlay_y + 1,
|
||||
overlay_width.saturating_sub(4),
|
||||
overlay_height.saturating_sub(2),
|
||||
);
|
||||
|
||||
// Get commands for this screen.
|
||||
let commands = registry.help_entries(screen);
|
||||
let visible_lines = inner.height as usize;
|
||||
|
||||
let key_cell = Cell {
|
||||
fg: text_color,
|
||||
..Cell::default()
|
||||
};
|
||||
let desc_cell = Cell {
|
||||
fg: muted_color,
|
||||
..Cell::default()
|
||||
};
|
||||
|
||||
for (i, cmd) in commands.iter().skip(scroll_offset).enumerate() {
|
||||
if i >= visible_lines {
|
||||
break;
|
||||
}
|
||||
let y = inner.y + i as u16;
|
||||
|
||||
// Key binding label (left).
|
||||
let key_label = cmd
|
||||
.keybinding
|
||||
.as_ref()
|
||||
.map_or_else(String::new, |kb| kb.display());
|
||||
let label_end = frame.print_text_clipped(inner.x, y, &key_label, key_cell, inner.right());
|
||||
|
||||
// Spacer + description (right).
|
||||
let desc_x = label_end.saturating_add(2);
|
||||
if desc_x < inner.right() {
|
||||
frame.print_text_clipped(desc_x, y, cmd.help_text, desc_cell, inner.right());
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll indicator if needed.
|
||||
if commands.len() > visible_lines + scroll_offset {
|
||||
let indicator = format!("({}/{})", scroll_offset + visible_lines, commands.len());
|
||||
let ind_x = inner.right().saturating_sub(indicator.len() as u16);
|
||||
let ind_y = overlay_rect.bottom().saturating_sub(1);
|
||||
frame.print_text_clipped(ind_x, ind_y, &indicator, desc_cell, overlay_rect.right());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::build_registry;
|
||||
use crate::message::Screen;
|
||||
use crate::navigation::NavigationStack;
|
||||
use crate::state::LoadState;
|
||||
use ftui::render::grapheme_pool::GraphemePool;
|
||||
|
||||
macro_rules! with_frame {
|
||||
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
||||
let mut pool = GraphemePool::new();
|
||||
let mut $frame = Frame::new($width, $height, &mut pool);
|
||||
$body
|
||||
}};
|
||||
}
|
||||
|
||||
fn white() -> PackedRgba {
|
||||
PackedRgba::rgb(0xFF, 0xFF, 0xFF)
|
||||
}
|
||||
|
||||
fn gray() -> PackedRgba {
|
||||
PackedRgba::rgb(0x80, 0x80, 0x80)
|
||||
}
|
||||
|
||||
fn red_bg() -> PackedRgba {
|
||||
PackedRgba::rgb(0xFF, 0x00, 0x00)
|
||||
}
|
||||
|
||||
// --- Breadcrumb tests ---
|
||||
|
||||
#[test]
|
||||
fn test_breadcrumb_single_screen() {
|
||||
with_frame!(80, 1, |frame| {
|
||||
let nav = NavigationStack::new(); // Dashboard only
|
||||
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray());
|
||||
|
||||
// Verify "Dashboard" was rendered by checking first cell.
|
||||
let cell = frame.buffer.get(0, 0).unwrap();
|
||||
assert!(
|
||||
cell.content.as_char() == Some('D'),
|
||||
"Expected 'D' at (0,0), got {:?}",
|
||||
cell.content.as_char()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breadcrumb_multi_screen() {
|
||||
with_frame!(80, 1, |frame| {
|
||||
let mut nav = NavigationStack::new();
|
||||
nav.push(Screen::IssueList);
|
||||
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 1), &nav, white(), gray());
|
||||
|
||||
// Should render "Dashboard > Issues"
|
||||
// Check 'D' at start and 'I' after separator.
|
||||
let d = frame.buffer.get(0, 0).unwrap();
|
||||
assert_eq!(d.content.as_char(), Some('D'));
|
||||
|
||||
// "Dashboard > Issues" = 'D' at 0, ' > ' at 9, 'I' at 12
|
||||
let i_cell = frame.buffer.get(12, 0).unwrap();
|
||||
assert_eq!(i_cell.content.as_char(), Some('I'));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breadcrumb_truncation() {
|
||||
// Very narrow terminal — should show "..." prefix.
|
||||
let crumbs = vec!["Dashboard", "Issues", "Issue"];
|
||||
let result = truncate_breadcrumb_left(&crumbs, " > ", 20);
|
||||
assert!(
|
||||
result.starts_with("..."),
|
||||
"Expected ellipsis prefix, got: {result}"
|
||||
);
|
||||
assert!(result.len() <= 20, "Result too long: {result}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_breadcrumb_zero_height_noop() {
|
||||
// Frame requires height >= 1, but Rect can have height=0.
|
||||
with_frame!(80, 1, |frame| {
|
||||
let nav = NavigationStack::new();
|
||||
// Should not panic — early return for zero-height area.
|
||||
render_breadcrumb(&mut frame, Rect::new(0, 0, 80, 0), &nav, white(), gray());
|
||||
});
|
||||
}
|
||||
|
||||
// --- Status bar tests ---
|
||||
|
||||
#[test]
|
||||
fn test_status_bar_renders_mode() {
|
||||
with_frame!(80, 1, |frame| {
|
||||
let registry = build_registry();
|
||||
render_status_bar(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 1),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Normal,
|
||||
gray(),
|
||||
white(),
|
||||
white(),
|
||||
);
|
||||
|
||||
// "NORMAL" should appear starting at x=1.
|
||||
let n_cell = frame.buffer.get(1, 0).unwrap();
|
||||
assert_eq!(n_cell.content.as_char(), Some('N'));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_bar_text_mode() {
|
||||
with_frame!(80, 1, |frame| {
|
||||
let registry = build_registry();
|
||||
render_status_bar(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 1),
|
||||
®istry,
|
||||
&Screen::Search,
|
||||
&InputMode::Text,
|
||||
gray(),
|
||||
white(),
|
||||
white(),
|
||||
);
|
||||
|
||||
// "INPUT" should appear at x=1.
|
||||
let i_cell = frame.buffer.get(1, 0).unwrap();
|
||||
assert_eq!(i_cell.content.as_char(), Some('I'));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status_bar_narrow_terminal() {
|
||||
with_frame!(4, 1, |frame| {
|
||||
let registry = build_registry();
|
||||
// Width < 5, should be a no-op.
|
||||
render_status_bar(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 4, 1),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
&InputMode::Normal,
|
||||
gray(),
|
||||
white(),
|
||||
white(),
|
||||
);
|
||||
// First cell should be empty (no-op).
|
||||
let cell = frame.buffer.get(0, 0).unwrap();
|
||||
assert!(cell.is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
// --- Loading indicator tests ---
|
||||
|
||||
#[test]
|
||||
fn test_loading_initial_renders_spinner() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
render_loading(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
&LoadState::LoadingInitial,
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
|
||||
// Spinner should be centered at y=12.
|
||||
// The spinner char is at the center position.
|
||||
let center_y = 12u16;
|
||||
// Find any non-empty cell on the center row.
|
||||
let has_content = (0..80u16).any(|x| {
|
||||
let cell = frame.buffer.get(x, center_y).unwrap();
|
||||
!cell.is_empty()
|
||||
});
|
||||
assert!(has_content, "Expected loading spinner at center row");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loading_refreshing_renders_corner() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
render_loading(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
&LoadState::Refreshing,
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
|
||||
// Corner spinner should be at (78, 0).
|
||||
let cell = frame.buffer.get(78, 0).unwrap();
|
||||
assert!(!cell.is_empty(), "Expected corner spinner");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loading_idle_noop() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
render_loading(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
&LoadState::Idle,
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
|
||||
// All cells should be empty.
|
||||
let has_content = (0..80u16).any(|x| {
|
||||
(0..24u16).any(|y| {
|
||||
let cell = frame.buffer.get(x, y).unwrap();
|
||||
!cell.is_empty()
|
||||
})
|
||||
});
|
||||
assert!(!has_content, "Idle state should render nothing");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_spinner_animation_cycles() {
|
||||
// Different tick values produce different spinner chars.
|
||||
let frame0 = spinner_char(0);
|
||||
let frame1 = spinner_char(1);
|
||||
let frame_wrap = spinner_char(SPINNER_FRAMES.len() as u64);
|
||||
|
||||
assert_ne!(frame0, frame1, "Adjacent frames should differ");
|
||||
assert_eq!(frame0, frame_wrap, "Should wrap around");
|
||||
}
|
||||
|
||||
// --- Error toast tests ---
|
||||
|
||||
#[test]
|
||||
fn test_error_toast_renders() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
render_error_toast(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
"Database is busy",
|
||||
red_bg(),
|
||||
white(),
|
||||
);
|
||||
|
||||
// Toast should be at bottom-right. Check row 22 (24-1-1).
|
||||
let y = 22u16;
|
||||
let has_content = (40..80u16).any(|x| {
|
||||
let cell = frame.buffer.get(x, y).unwrap();
|
||||
!cell.is_empty()
|
||||
});
|
||||
assert!(has_content, "Expected error toast at bottom-right");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_toast_empty_message_noop() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
render_error_toast(&mut frame, Rect::new(0, 0, 80, 24), "", red_bg(), white());
|
||||
|
||||
// No content should be rendered.
|
||||
let has_content = (0..80u16).any(|x| {
|
||||
(0..24u16).any(|y| {
|
||||
let cell = frame.buffer.get(x, y).unwrap();
|
||||
!cell.is_empty()
|
||||
})
|
||||
});
|
||||
assert!(!has_content, "Empty message should render nothing");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_toast_truncates_long_message() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let long_msg = "A".repeat(200);
|
||||
// Should not panic and should truncate.
|
||||
render_error_toast(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
&long_msg,
|
||||
red_bg(),
|
||||
white(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Help overlay tests ---
|
||||
|
||||
#[test]
|
||||
fn test_help_overlay_renders_border() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let registry = build_registry();
|
||||
render_help_overlay(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 80, 24),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
gray(),
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
|
||||
// The overlay should have non-empty cells in the center area.
|
||||
let has_content = (20..60u16).any(|x| {
|
||||
(8..16u16).any(|y| {
|
||||
let cell = frame.buffer.get(x, y).unwrap();
|
||||
!cell.is_empty()
|
||||
})
|
||||
});
|
||||
assert!(has_content, "Expected help overlay in center area");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help_overlay_tiny_terminal_noop() {
|
||||
with_frame!(15, 4, |frame| {
|
||||
let registry = build_registry();
|
||||
// Too small — should be a no-op.
|
||||
render_help_overlay(
|
||||
&mut frame,
|
||||
Rect::new(0, 0, 15, 4),
|
||||
®istry,
|
||||
&Screen::Dashboard,
|
||||
gray(),
|
||||
white(),
|
||||
gray(),
|
||||
0,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Truncation helper tests ---
|
||||
|
||||
#[test]
|
||||
fn test_truncate_breadcrumb_fits() {
|
||||
let crumbs = vec!["A", "B"];
|
||||
let result = truncate_breadcrumb_left(&crumbs, " > ", 100);
|
||||
// When it doesn't need truncation, this function is not called
|
||||
// in practice, but it should still work.
|
||||
assert!(result.contains("..."), "Should always add ellipsis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_breadcrumb_single_entry() {
|
||||
let crumbs = vec!["Dashboard"];
|
||||
let result = truncate_breadcrumb_left(&crumbs, " > ", 5);
|
||||
assert_eq!(result, "...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_breadcrumb_shows_last_entries() {
|
||||
let crumbs = vec!["Dashboard", "Issues", "Issue Detail"];
|
||||
let result = truncate_breadcrumb_left(&crumbs, " > ", 30);
|
||||
assert!(result.starts_with("..."));
|
||||
assert!(result.contains("Issue Detail"));
|
||||
}
|
||||
}
|
||||
185
crates/lore-tui/src/view/mod.rs
Normal file
185
crates/lore-tui/src/view/mod.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
#![allow(dead_code)] // Phase 1: screen content renders added in Phase 2+
|
||||
|
||||
//! Top-level view dispatch for the lore TUI.
|
||||
//!
|
||||
//! [`render_screen`] is the entry point called from `LoreApp::view()`.
|
||||
//! It composes the layout: breadcrumb bar, screen content area, status
|
||||
//! bar, and optional overlays (help, error toast).
|
||||
|
||||
pub mod common;
|
||||
|
||||
use ftui::layout::{Constraint, Flex};
|
||||
use ftui::render::cell::PackedRgba;
|
||||
use ftui::render::frame::Frame;
|
||||
|
||||
use crate::app::LoreApp;
|
||||
|
||||
use common::{
|
||||
render_breadcrumb, render_error_toast, render_help_overlay, render_loading, render_status_bar,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors (hardcoded Flexoki palette — will use Theme in Phase 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
|
||||
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
||||
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
|
||||
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
|
||||
const ERROR_BG: PackedRgba = PackedRgba::rgb(0xAF, 0x3A, 0x29); // red
|
||||
const ERROR_FG: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
|
||||
const BORDER: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render_screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Top-level view dispatch: composes breadcrumb + content + status bar + overlays.
|
||||
///
|
||||
/// Called from `LoreApp::view()`. The layout is:
|
||||
/// ```text
|
||||
/// +-----------------------------------+
|
||||
/// | Breadcrumb (1 row) |
|
||||
/// +-----------------------------------+
|
||||
/// | |
|
||||
/// | Screen content (fill) |
|
||||
/// | |
|
||||
/// +-----------------------------------+
|
||||
/// | Status bar (1 row) |
|
||||
/// +-----------------------------------+
|
||||
/// ```
|
||||
///
|
||||
/// Overlays (help, error toast) render on top of existing content.
|
||||
pub fn render_screen(frame: &mut Frame<'_>, app: &LoreApp) {
|
||||
let bounds = frame.bounds();
|
||||
if bounds.width < 3 || bounds.height < 3 {
|
||||
return; // Terminal too small to render anything useful.
|
||||
}
|
||||
|
||||
// Split vertically: breadcrumb (1) | content (fill) | status bar (1).
|
||||
let regions = Flex::vertical()
|
||||
.constraints([
|
||||
Constraint::Fixed(1), // breadcrumb
|
||||
Constraint::Fill, // content
|
||||
Constraint::Fixed(1), // status bar
|
||||
])
|
||||
.split(bounds);
|
||||
|
||||
let breadcrumb_area = regions[0];
|
||||
let content_area = regions[1];
|
||||
let status_area = regions[2];
|
||||
|
||||
let screen = app.navigation.current();
|
||||
|
||||
// --- Breadcrumb ---
|
||||
render_breadcrumb(frame, breadcrumb_area, &app.navigation, TEXT, TEXT_MUTED);
|
||||
|
||||
// --- Screen content ---
|
||||
let load_state = app.state.load_state.get(screen);
|
||||
// tick=0 placeholder — animation wired up when Msg::Tick increments a counter.
|
||||
render_loading(frame, content_area, load_state, TEXT, TEXT_MUTED, 0);
|
||||
|
||||
// Per-screen content dispatch (Phase 2+).
|
||||
// match screen {
|
||||
// Screen::Dashboard => ...,
|
||||
// Screen::IssueList => ...,
|
||||
// ...
|
||||
// }
|
||||
|
||||
// --- Status bar ---
|
||||
render_status_bar(
|
||||
frame,
|
||||
status_area,
|
||||
&app.command_registry,
|
||||
screen,
|
||||
&app.input_mode,
|
||||
BG_SURFACE,
|
||||
TEXT,
|
||||
ACCENT,
|
||||
);
|
||||
|
||||
// --- Overlays (render last, on top of everything) ---
|
||||
|
||||
// Error toast.
|
||||
if let Some(ref error_msg) = app.state.error_toast {
|
||||
render_error_toast(frame, bounds, error_msg, ERROR_BG, ERROR_FG);
|
||||
}
|
||||
|
||||
// Help overlay.
|
||||
if app.state.show_help {
|
||||
render_help_overlay(
|
||||
frame,
|
||||
bounds,
|
||||
&app.command_registry,
|
||||
screen,
|
||||
BORDER,
|
||||
TEXT,
|
||||
TEXT_MUTED,
|
||||
0, // scroll_offset — tracked in future phase
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::LoreApp;
|
||||
use ftui::render::grapheme_pool::GraphemePool;
|
||||
|
||||
macro_rules! with_frame {
|
||||
($width:expr, $height:expr, |$frame:ident| $body:block) => {{
|
||||
let mut pool = GraphemePool::new();
|
||||
let mut $frame = Frame::new($width, $height, &mut pool);
|
||||
$body
|
||||
}};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_screen_does_not_panic() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let app = LoreApp::new();
|
||||
render_screen(&mut frame, &app);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_screen_tiny_terminal_noop() {
|
||||
with_frame!(2, 2, |frame| {
|
||||
let app = LoreApp::new();
|
||||
render_screen(&mut frame, &app);
|
||||
// Should not panic — early return for tiny terminals.
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_screen_with_error_toast() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let mut app = LoreApp::new();
|
||||
app.state.set_error("test error".into());
|
||||
render_screen(&mut frame, &app);
|
||||
// Should render without panicking.
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_screen_with_help_overlay() {
|
||||
with_frame!(80, 24, |frame| {
|
||||
let mut app = LoreApp::new();
|
||||
app.state.show_help = true;
|
||||
render_screen(&mut frame, &app);
|
||||
// Should render without panicking.
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_screen_narrow_terminal() {
|
||||
with_frame!(20, 5, |frame| {
|
||||
let app = LoreApp::new();
|
||||
render_screen(&mut frame, &app);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,20 @@ use crate::core::paths::get_db_path;
|
||||
use crate::core::project::resolve_project;
|
||||
use crate::core::time::{ms_to_iso, now_ms, parse_since};
|
||||
|
||||
// ─── Decay Math ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Exponential half-life decay: R = 2^(-t/h)
|
||||
/// Returns 1.0 at elapsed=0, 0.5 at elapsed=half_life, 0.0 if half_life=0.
|
||||
#[allow(dead_code)] // Used by bd-13q8 (decay aggregation)
|
||||
fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
|
||||
let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0);
|
||||
let hl = f64::from(half_life_days);
|
||||
if hl <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
2.0_f64.powf(-days / hl)
|
||||
}
|
||||
|
||||
// ─── Mode Discrimination ────────────────────────────────────────────────────
|
||||
|
||||
/// Determines which query mode to run based on args.
|
||||
@@ -3568,6 +3582,7 @@ mod tests {
|
||||
author_weight: 5,
|
||||
reviewer_weight: 30,
|
||||
note_bonus: 1,
|
||||
..ScoringConfig::default()
|
||||
};
|
||||
let result = query_expert(&conn, "src/app.rs", None, 0, 20, &flipped, false).unwrap();
|
||||
assert_eq!(result.experts[0].username, "the_reviewer");
|
||||
@@ -3690,4 +3705,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── half_life_decay tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_half_life_decay_math() {
|
||||
let hl_180 = 180;
|
||||
// At t=0, full retention
|
||||
assert!((half_life_decay(0, hl_180) - 1.0).abs() < f64::EPSILON);
|
||||
// At t=half_life, exactly 0.5
|
||||
let one_hl_ms = 180 * 86_400_000_i64;
|
||||
assert!((half_life_decay(one_hl_ms, hl_180) - 0.5).abs() < 1e-10);
|
||||
// At t=2*half_life, exactly 0.25
|
||||
assert!((half_life_decay(2 * one_hl_ms, hl_180) - 0.25).abs() < 1e-10);
|
||||
// Negative elapsed clamped to 0 -> 1.0
|
||||
assert!((half_life_decay(-1000, hl_180) - 1.0).abs() < f64::EPSILON);
|
||||
// Zero half-life -> 0.0 (div-by-zero guard)
|
||||
assert!((half_life_decay(86_400_000, 0)).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_score_monotonicity_by_age() {
|
||||
let mut seed: u64 = 42;
|
||||
let hl = 90_u32;
|
||||
for _ in 0..50 {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
let newer_ms = (seed % 100_000_000) as i64;
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
let older_ms = newer_ms + (seed % 500_000_000) as i64;
|
||||
assert!(
|
||||
half_life_decay(older_ms, hl) <= half_life_decay(newer_ms, hl),
|
||||
"Monotonicity violated: decay({older_ms}) > decay({newer_ms})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,38 @@ pub struct ScoringConfig {
|
||||
/// Bonus points per individual inline review comment (DiffNote).
|
||||
#[serde(rename = "noteBonus")]
|
||||
pub note_bonus: i64,
|
||||
|
||||
/// Points for being assigned as reviewer (without substantive notes).
|
||||
#[serde(rename = "reviewerAssignmentWeight")]
|
||||
pub reviewer_assignment_weight: i64,
|
||||
|
||||
/// Half-life in days for author contribution decay.
|
||||
#[serde(rename = "authorHalfLifeDays")]
|
||||
pub author_half_life_days: u32,
|
||||
|
||||
/// Half-life in days for reviewer participation decay.
|
||||
#[serde(rename = "reviewerHalfLifeDays")]
|
||||
pub reviewer_half_life_days: u32,
|
||||
|
||||
/// Half-life in days for reviewer-assignment-only decay.
|
||||
#[serde(rename = "reviewerAssignmentHalfLifeDays")]
|
||||
pub reviewer_assignment_half_life_days: u32,
|
||||
|
||||
/// Half-life in days for note/comment decay.
|
||||
#[serde(rename = "noteHalfLifeDays")]
|
||||
pub note_half_life_days: u32,
|
||||
|
||||
/// Multiplier applied to closed (not merged) MRs.
|
||||
#[serde(rename = "closedMrMultiplier")]
|
||||
pub closed_mr_multiplier: f64,
|
||||
|
||||
/// Minimum character count for a reviewer note to be "substantive".
|
||||
#[serde(rename = "reviewerMinNoteChars")]
|
||||
pub reviewer_min_note_chars: u32,
|
||||
|
||||
/// Usernames to exclude from scoring (e.g. bots).
|
||||
#[serde(rename = "excludedUsernames")]
|
||||
pub excluded_usernames: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for ScoringConfig {
|
||||
@@ -172,6 +204,14 @@ impl Default for ScoringConfig {
|
||||
author_weight: 25,
|
||||
reviewer_weight: 10,
|
||||
note_bonus: 1,
|
||||
reviewer_assignment_weight: 3,
|
||||
author_half_life_days: 180,
|
||||
reviewer_half_life_days: 90,
|
||||
reviewer_assignment_half_life_days: 45,
|
||||
note_half_life_days: 45,
|
||||
closed_mr_multiplier: 0.5,
|
||||
reviewer_min_note_chars: 20,
|
||||
excluded_usernames: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,6 +327,51 @@ fn validate_scoring(scoring: &ScoringConfig) -> Result<()> {
|
||||
details: "scoring.noteBonus must be >= 0".to_string(),
|
||||
});
|
||||
}
|
||||
if scoring.reviewer_assignment_weight < 0 {
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.reviewerAssignmentWeight must be >= 0".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
for (field, value) in [
|
||||
("authorHalfLifeDays", scoring.author_half_life_days),
|
||||
("reviewerHalfLifeDays", scoring.reviewer_half_life_days),
|
||||
(
|
||||
"reviewerAssignmentHalfLifeDays",
|
||||
scoring.reviewer_assignment_half_life_days,
|
||||
),
|
||||
("noteHalfLifeDays", scoring.note_half_life_days),
|
||||
] {
|
||||
if value == 0 || value > 3650 {
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: format!("scoring.{field} must be > 0 and <= 3650"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !scoring.closed_mr_multiplier.is_finite()
|
||||
|| scoring.closed_mr_multiplier <= 0.0
|
||||
|| scoring.closed_mr_multiplier > 1.0
|
||||
{
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.closedMrMultiplier must be finite and in (0.0, 1.0]".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if scoring.reviewer_min_note_chars > 4096 {
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.reviewerMinNoteChars must be <= 4096".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
for username in &scoring.excluded_usernames {
|
||||
if username.is_empty() {
|
||||
return Err(LoreError::ConfigInvalid {
|
||||
details: "scoring.excludedUsernames entries must be non-empty".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -543,6 +628,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_zero_half_life() {
|
||||
let mut cfg = ScoringConfig::default();
|
||||
assert!(validate_scoring(&cfg).is_ok());
|
||||
cfg.author_half_life_days = 0;
|
||||
assert!(validate_scoring(&cfg).is_err());
|
||||
cfg.author_half_life_days = 180;
|
||||
cfg.reviewer_half_life_days = 0;
|
||||
assert!(validate_scoring(&cfg).is_err());
|
||||
cfg.reviewer_half_life_days = 90;
|
||||
cfg.closed_mr_multiplier = 0.0;
|
||||
assert!(validate_scoring(&cfg).is_err());
|
||||
cfg.closed_mr_multiplier = 1.5;
|
||||
assert!(validate_scoring(&cfg).is_err());
|
||||
cfg.closed_mr_multiplier = 1.0;
|
||||
assert!(validate_scoring(&cfg).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_absurd_half_life() {
|
||||
let mut cfg = ScoringConfig::default();
|
||||
cfg.author_half_life_days = 5000; // > 3650 cap
|
||||
assert!(validate_scoring(&cfg).is_err());
|
||||
cfg.author_half_life_days = 3650; // boundary: valid
|
||||
assert!(validate_scoring(&cfg).is_ok());
|
||||
cfg.reviewer_min_note_chars = 5000; // > 4096 cap
|
||||
assert!(validate_scoring(&cfg).is_err());
|
||||
cfg.reviewer_min_note_chars = 4096; // boundary: valid
|
||||
assert!(validate_scoring(&cfg).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_nan_multiplier() {
|
||||
let mut cfg = ScoringConfig::default();
|
||||
cfg.closed_mr_multiplier = f64::NAN;
|
||||
assert!(validate_scoring(&cfg).is_err());
|
||||
cfg.closed_mr_multiplier = f64::INFINITY;
|
||||
assert!(validate_scoring(&cfg).is_err());
|
||||
cfg.closed_mr_multiplier = f64::NEG_INFINITY;
|
||||
assert!(validate_scoring(&cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_negative_assignment_weight() {
|
||||
let mut cfg = ScoringConfig::default();
|
||||
cfg.reviewer_assignment_weight = -1;
|
||||
assert!(validate_scoring(&cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation_rejects_empty_excluded_username() {
|
||||
let mut cfg = ScoringConfig::default();
|
||||
cfg.excluded_usernames = vec!["".to_string()];
|
||||
assert!(validate_scoring(&cfg).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scoring_config_defaults() {
|
||||
let cfg = ScoringConfig::default();
|
||||
assert_eq!(cfg.author_weight, 25);
|
||||
assert_eq!(cfg.reviewer_weight, 10);
|
||||
assert_eq!(cfg.note_bonus, 1);
|
||||
assert_eq!(cfg.reviewer_assignment_weight, 3);
|
||||
assert_eq!(cfg.author_half_life_days, 180);
|
||||
assert_eq!(cfg.reviewer_half_life_days, 90);
|
||||
assert_eq!(cfg.reviewer_assignment_half_life_days, 45);
|
||||
assert_eq!(cfg.note_half_life_days, 45);
|
||||
assert!((cfg.closed_mr_multiplier - 0.5).abs() < f64::EPSILON);
|
||||
assert_eq!(cfg.reviewer_min_note_chars, 20);
|
||||
assert!(cfg.excluded_usernames.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minimal_config_includes_default_project_when_set() {
|
||||
let config = MinimalConfig {
|
||||
|
||||
Reference in New Issue
Block a user