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)
650 lines
24 KiB
Rust
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);
|
|
}
|
|
}
|