Implement state, action, and view layers for both list screens: - Issue List: keyset pagination, snapshot fence, filter DSL, label aggregation - MR List: mirrors Issue pattern with draft/reviewer/target branch filters - Migration 027: covering indexes for TUI list screen queries - Updated Msg types to use typed Page structs instead of raw Vec<Row> - 303 tests passing, clippy clean Beads: bd-3ei1, bd-2kr0, bd-3pm2
368 lines
13 KiB
Rust
368 lines
13 KiB
Rust
//! Model trait impl and key dispatch for LoreApp.
|
|
|
|
use chrono::TimeDelta;
|
|
use ftui::{Cmd, Event, Frame, KeyCode, KeyEvent, Model, Modifiers};
|
|
|
|
use crate::crash_context::CrashEvent;
|
|
use crate::message::{InputMode, Msg, Screen};
|
|
use crate::state::LoadState;
|
|
use crate::task_supervisor::TaskKey;
|
|
|
|
use super::LoreApp;
|
|
|
|
/// Timeout for the g-prefix key sequence.
|
|
const GO_PREFIX_TIMEOUT: TimeDelta = TimeDelta::milliseconds(500);
|
|
|
|
impl LoreApp {
|
|
// -----------------------------------------------------------------------
|
|
// 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).
|
|
pub(crate) 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());
|
|
|
|
// First visit → full-screen spinner; revisit → corner spinner over stale data.
|
|
let load_state = if self.state.load_state.was_visited(&screen) {
|
|
LoadState::Refreshing
|
|
} else {
|
|
LoadState::LoadingInitial
|
|
};
|
|
self.state.set_loading(screen.clone(), load_state);
|
|
|
|
// 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.
|
|
pub(crate) 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, page } => {
|
|
if self
|
|
.supervisor
|
|
.is_current(&TaskKey::LoadScreen(Screen::IssueList), generation)
|
|
{
|
|
self.state.issue_list.apply_page(page);
|
|
self.state.set_loading(Screen::IssueList, LoadState::Idle);
|
|
self.supervisor
|
|
.complete(&TaskKey::LoadScreen(Screen::IssueList), generation);
|
|
}
|
|
Cmd::none()
|
|
}
|
|
Msg::MrListLoaded { generation, page } => {
|
|
if self
|
|
.supervisor
|
|
.is_current(&TaskKey::LoadScreen(Screen::MrList), generation)
|
|
{
|
|
self.state.mr_list.apply_page(page);
|
|
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.update(*data);
|
|
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 Model for LoreApp {
|
|
type Message = Msg;
|
|
|
|
fn init(&mut self) -> Cmd<Self::Message> {
|
|
// Install crash context panic hook.
|
|
crate::crash_context::CrashContext::install_panic_hook(&self.crash_context);
|
|
crate::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> {
|
|
// Route raw key events through the 5-stage pipeline.
|
|
if let Msg::RawEvent(Event::Key(key)) = msg {
|
|
return self.interpret_key(key);
|
|
}
|
|
|
|
// Everything else goes through message dispatch.
|
|
self.handle_msg(msg)
|
|
}
|
|
|
|
fn view(&self, frame: &mut Frame) {
|
|
crate::view::render_screen(frame, self);
|
|
}
|
|
}
|