feat(tui): Phase 2 Issue List + MR List screens
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
This commit is contained in:
367
crates/lore-tui/src/app/update.rs
Normal file
367
crates/lore-tui/src/app/update.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user