//! 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 { 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 { // 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 { 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 { 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 { 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 { 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 { // 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 { // 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 { // 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); } }