Files
gitlore/crates/lore-tui/src/app/update.rs
teernisse 1b66b80ac4 style(tui): apply rustfmt and clippy formatting across crate
Mechanical formatting pass to satisfy rustfmt line-width limits and
clippy pedantic/nursery lints. No behavioral changes.

Formatting (rustfmt line wrapping):
- action/sync.rs: multiline tuple destructure, function call args in tests
- state/sync.rs: if-let chain formatting, remove unnecessary Vec collect
- view/sync.rs: multiline array entries, format!(), vec! literals
- view/doctor.rs: multiline floor_char_boundary chain
- view/scope_picker.rs: multiline format!() with floor_char_boundary
- view/stats.rs: multiline render_stat_row call
- view/mod.rs: multiline assert!() in test
- app/update.rs: multiline enum variant destructure
- entity_cache.rs: multiline assert_eq!() with messages
- render_cache.rs: multiline retain() closure
- session.rs: multiline serde_json/File::create/parent() chains

Clippy:
- action/sync.rs: #[allow(clippy::too_many_arguments)] on test helper

Import/module ordering (alphabetical):
- state/mod.rs: move scope_picker mod + pub use to sorted position
- view/mod.rs: move scope_picker, stats, sync mod + use to sorted position
- view/scope_picker.rs: sort use imports (ScopeContext before ScopePickerState)
2026-02-18 23:58:29 -05:00

650 lines
24 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> {
match key.code {
KeyCode::Escape => {
self.state.command_palette.close();
self.input_mode = InputMode::Normal;
Cmd::none()
}
KeyCode::Enter => {
if let Some(cmd_id) = self.state.command_palette.selected_command_id() {
self.state.command_palette.close();
self.input_mode = InputMode::Normal;
self.execute_command(cmd_id, screen)
} else {
Cmd::none()
}
}
KeyCode::Up => {
self.state.command_palette.select_prev();
Cmd::none()
}
KeyCode::Down => {
self.state.command_palette.select_next();
Cmd::none()
}
KeyCode::Backspace => {
self.state
.command_palette
.delete_back(&self.command_registry, screen);
Cmd::none()
}
KeyCode::Char(c) => {
self.state
.command_palette
.insert_char(c, &self.command_registry, screen);
Cmd::none()
}
_ => 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;
let screen = self.navigation.current().clone();
self.state
.command_palette
.open(&self.command_registry, &screen);
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_file_history" => self.navigate_to(Screen::FileHistory),
"go_trace" => self.navigate_to(Screen::Trace),
"go_doctor" => self.navigate_to(Screen::Doctor),
"go_stats" => self.navigate_to(Screen::Stats),
"go_sync" => {
if screen == &Screen::Bootstrap {
self.state.bootstrap.sync_started = true;
Cmd::none()
} else {
self.navigate_to(Screen::Sync)
}
}
"jump_back" => {
self.navigation.jump_back();
Cmd::none()
}
"jump_forward" => {
self.navigation.jump_forward();
Cmd::none()
}
"toggle_scope" => {
if self.state.scope_picker.visible {
self.state.scope_picker.close();
Cmd::none()
} else {
// Fetch projects and open picker asynchronously.
Cmd::task(move || {
// The actual DB query runs in the task; for now, open
// immediately with cached projects if available.
Msg::ScopeProjectsLoaded { projects: vec![] }
})
}
}
"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: msg.variant_name().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()
}
// --- Issue detail ---
Msg::IssueDetailLoaded {
generation,
key,
data,
} => {
let screen = Screen::IssueDetail(key.clone());
if self
.supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{
self.state.issue_detail.apply_metadata(*data);
self.state.set_loading(screen.clone(), LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(screen), generation);
}
Cmd::none()
}
Msg::DiscussionsLoaded {
generation: _,
key,
discussions,
} => {
// Progressive hydration: the parent detail task already called
// supervisor.complete(), so is_current() would return false.
// Instead, check that the detail state still expects this key.
match key.kind {
crate::message::EntityKind::Issue => {
if self.state.issue_detail.current_key.as_ref() == Some(&key) {
self.state.issue_detail.apply_discussions(discussions);
}
}
crate::message::EntityKind::MergeRequest => {
if self.state.mr_detail.current_key.as_ref() == Some(&key) {
self.state.mr_detail.apply_discussions(discussions);
}
}
}
Cmd::none()
}
// --- MR detail ---
Msg::MrDetailLoaded {
generation,
key,
data,
} => {
let screen = Screen::MrDetail(key.clone());
if self
.supervisor
.is_current(&TaskKey::LoadScreen(screen.clone()), generation)
{
self.state.mr_detail.apply_metadata(*data);
self.state.set_loading(screen.clone(), LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(screen), generation);
}
Cmd::none()
}
// --- Sync lifecycle ---
Msg::SyncStarted => {
self.state.sync.start();
if *self.navigation.current() == Screen::Bootstrap {
self.state.bootstrap.sync_started = true;
}
Cmd::none()
}
Msg::SyncProgress {
stage,
current,
total,
} => {
self.state.sync.update_progress(&stage, current, total);
Cmd::none()
}
Msg::SyncProgressBatch { stage, batch_size } => {
self.state.sync.update_batch(&stage, batch_size);
Cmd::none()
}
Msg::SyncLogLine(line) => {
self.state.sync.add_log_line(line);
Cmd::none()
}
Msg::SyncBackpressureDrop => {
// Silently drop — the coalescer already handles throttling.
Cmd::none()
}
Msg::SyncCompleted { elapsed_ms } => {
self.state.sync.complete(elapsed_ms);
// If we came from Bootstrap, replace nav history with Dashboard.
if *self.navigation.current() == Screen::Bootstrap {
self.state.bootstrap.sync_started = false;
self.navigation.reset_to(Screen::Dashboard);
// Trigger a fresh dashboard load without preserving Bootstrap in history.
let dashboard = Screen::Dashboard;
let load_state = if self.state.load_state.was_visited(&dashboard) {
LoadState::Refreshing
} else {
LoadState::LoadingInitial
};
self.state.set_loading(dashboard.clone(), load_state);
let _handle = self.supervisor.submit(TaskKey::LoadScreen(dashboard));
}
Cmd::none()
}
Msg::SyncCancelled => {
self.state.sync.cancel();
Cmd::none()
}
Msg::SyncFailed(err) => {
self.state.sync.fail(err);
Cmd::none()
}
Msg::SyncStreamStats { bytes, items } => {
self.state.sync.update_stream_stats(bytes, items);
Cmd::none()
}
// --- Who screen ---
Msg::WhoResultLoaded { generation, result } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Who), generation)
{
self.state.who.apply_results(generation, *result);
self.state.set_loading(Screen::Who, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Who), generation);
}
Cmd::none()
}
Msg::WhoModeChanged => {
// Mode tab changed — view will re-render from state.
Cmd::none()
}
// --- File History screen ---
Msg::FileHistoryLoaded { generation, result } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::FileHistory), generation)
{
self.state.file_history.apply_results(generation, *result);
self.state.set_loading(Screen::FileHistory, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::FileHistory), generation);
}
Cmd::none()
}
Msg::FileHistoryKnownPathsLoaded { paths } => {
self.state.file_history.known_paths = paths;
Cmd::none()
}
// --- Trace screen ---
Msg::TraceResultLoaded { generation, result } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Trace), generation)
{
self.state.trace.apply_result(generation, *result);
self.state.set_loading(Screen::Trace, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Trace), generation);
}
Cmd::none()
}
Msg::TraceKnownPathsLoaded { paths } => {
self.state.trace.known_paths = paths;
Cmd::none()
}
// --- Doctor ---
Msg::DoctorLoaded { checks } => {
self.state.doctor.apply_checks(checks);
self.state.set_loading(Screen::Doctor, LoadState::Idle);
Cmd::none()
}
// --- Stats ---
Msg::StatsLoaded { data } => {
self.state.stats.apply_data(data);
self.state.set_loading(Screen::Stats, LoadState::Idle);
Cmd::none()
}
// --- Timeline ---
Msg::TimelineLoaded { generation, events } => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Timeline), generation)
{
self.state.timeline.apply_results(generation, events);
self.state.set_loading(Screen::Timeline, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Timeline), generation);
}
Cmd::none()
}
// --- Search ---
Msg::SearchExecuted {
generation,
results,
} => {
if self
.supervisor
.is_current(&TaskKey::LoadScreen(Screen::Search), generation)
{
self.state.search.apply_results(generation, results);
self.state.set_loading(Screen::Search, LoadState::Idle);
self.supervisor
.complete(&TaskKey::LoadScreen(Screen::Search), generation);
}
Cmd::none()
}
// --- Scope ---
Msg::ScopeProjectsLoaded { projects } => {
self.state
.scope_picker
.open(projects, &self.state.global_scope);
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);
}
}