From 500b920c02b68aebad4f0b47900bd6e640d80330 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 12:57:54 -0300 Subject: [PATCH 1/6] refactor(app): add AppTransition, AppMessage, and AppError variants This commit introduces the application actor protocol without changing runtime behavior. AppMessage defines the control and query messages for the upcoming AppActor, AppTransition models frame-cycle outcomes, and AppError gains variants for UI, input, and dependency failures at the actor boundary. This commit is part of the architecture's refactoring phase 12. Signed-off-by: lorenzoberts --- src/app/errors.rs | 16 ++++++++++++---- src/app/messages.rs | 39 +++++++++++++++++++++++++++++++++++++++ src/app/mod.rs | 2 ++ src/app/transitions.rs | 10 ++++++++++ 4 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 src/app/messages.rs create mode 100644 src/app/transitions.rs diff --git a/src/app/errors.rs b/src/app/errors.rs index f364bc8..40346ad 100644 --- a/src/app/errors.rs +++ b/src/app/errors.rs @@ -1,12 +1,11 @@ use thiserror::Error; -use crate::lore::application::errors::LoreError; +use crate::{lore::application::errors::LoreError, ui::errors::UiError}; +#[allow(dead_code)] /// Errors surfaced by the application orchestration layer (`App`). /// -/// Used as the typed boundary for future App-actor messages; many methods still -/// return `color_eyre::Result` during the refactor. -#[allow(dead_code)] +/// Used as the typed boundary for `AppActor` messages and startup validation. #[derive(Debug, Error)] pub enum AppError { #[error("invalid navigation state: {0}")] @@ -20,4 +19,13 @@ pub enum AppError { #[error("config error: {0}")] Config(String), + + #[error("ui error: {0}")] + Ui(#[from] UiError), + + #[error("input error: {0}")] + Input(String), + + #[error("missing required dependencies: {0}")] + Dependencies(String), } diff --git a/src/app/messages.rs b/src/app/messages.rs new file mode 100644 index 0000000..5dc28c6 --- /dev/null +++ b/src/app/messages.rs @@ -0,0 +1,39 @@ +use std::ops::ControlFlow; + +use tokio::sync::oneshot; + +use crate::{ + app::view_model::AppViewModel, + input::{context::InputContext, event::InputEvent}, +}; + +#[allow(dead_code)] +/// Typed message protocol for the `AppActor`. +/// +/// External callers communicate with the actor exclusively through these +/// variants rather than touching `App` state directly. +pub enum AppMessage { + /// Request the actor to perform startup validation. + Initialize, + + /// Inject a synthetic input event for processing. + Input { + event: InputEvent, + reply_to: oneshot::Sender>>, + }, + + /// Request a snapshot of the current presentation model. + GetViewModel { + reply_to: oneshot::Sender, + }, + + /// Request the current input context for the input mapper. + GetInputContext { + reply_to: oneshot::Sender, + }, + + /// Request the actor to stop its run loop and exit cleanly. + Shutdown { + reply_to: oneshot::Sender<()>, + }, +} diff --git a/src/app/mod.rs b/src/app/mod.rs index e8f05f3..0697731 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,9 +1,11 @@ pub mod commands; pub mod errors; pub mod input; +pub mod messages; pub mod popup; pub mod screens; pub mod state; +pub mod transitions; pub mod updates; pub mod view_model; diff --git a/src/app/transitions.rs b/src/app/transitions.rs new file mode 100644 index 0000000..b176fa8 --- /dev/null +++ b/src/app/transitions.rs @@ -0,0 +1,10 @@ +#[allow(dead_code)] +/// Signals the outcome of a single user-input or system-driven state update. +pub enum AppTransition { + /// No state was mutated; the current frame can be reused. + Noop, + /// App state changed and a new frame should be rendered. + StateChanged, + /// The user requested the application to exit. + ExitRequested, +} From 802a8360c70a43e8ac1a87b0988c986e6345d7db Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 13:01:23 -0300 Subject: [PATCH 2/6] refactor(app): introduce AppActor scaffold and wire into runtime This commit wraps the existing event loop in an AppActor and AppHandle without changing application behavior. main now spawns the actor and awaits run_until_done, establishing the actor lifecycle entry point while run logic still flows through the transitional handler path. This commit is part of the architecture's refactoring phase 12. Signed-off-by: lorenzoberts --- src/app/actor.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++ src/app/handle.rs | 21 ++++++++++++++++++ src/app/mod.rs | 3 +++ src/app/updates.rs | 2 +- src/handler/mod.rs | 4 ++-- src/main.rs | 6 ++--- 6 files changed, 85 insertions(+), 6 deletions(-) create mode 100644 src/app/actor.rs create mode 100644 src/app/handle.rs diff --git a/src/app/actor.rs b/src/app/actor.rs new file mode 100644 index 0000000..aea645e --- /dev/null +++ b/src/app/actor.rs @@ -0,0 +1,55 @@ +use tokio::sync::mpsc; + +use crate::{ + app::{handle::AppHandle, App}, + handler, + input::{event::InputEvent, handle::InputHandle}, + terminal::handle::TerminalHandle, + ui::handle::UiHandle, +}; + +/// Owns `App` state and drives the main application loop on a dedicated task. +/// +/// Constructed via [`AppActor::spawn`], which moves all owned resources into +/// the actor and returns an [`AppHandle`] to the caller. The run loop itself +/// is identical to the former `handler::run_app` function; the move into an +/// actor boundary is the only change in this commit. +pub struct AppActor { + app: App, + terminal_handle: TerminalHandle, + ui_handle: UiHandle, + input_handle: InputHandle, + event_rx: mpsc::Receiver, +} + +impl AppActor { + /// Moves all resources into a new `AppActor`, spawns it on the Tokio + /// runtime, and returns an [`AppHandle`] to wait on its completion. + pub fn spawn( + app: App, + terminal_handle: TerminalHandle, + ui_handle: UiHandle, + input_handle: InputHandle, + event_rx: mpsc::Receiver, + ) -> AppHandle { + let actor = Self { + app, + terminal_handle, + ui_handle, + input_handle, + event_rx, + }; + AppHandle::new(tokio::spawn(actor.run())) + } + + async fn run(self) -> color_eyre::Result<()> { + handler::run_app( + self.app, + self.terminal_handle, + self.ui_handle, + self.input_handle, + self.event_rx, + ) + .await + } +} diff --git a/src/app/handle.rs b/src/app/handle.rs new file mode 100644 index 0000000..b41dd7c --- /dev/null +++ b/src/app/handle.rs @@ -0,0 +1,21 @@ +use tokio::task::JoinHandle; + +/// Cloneable-free handle to the `AppActor` task. +/// +/// The only operation available at this stage is waiting for the actor to +/// finish. A `Sender` and richer async methods are wired in a later commit. +pub struct AppHandle { + join: JoinHandle>, +} + +impl AppHandle { + pub fn new(join: JoinHandle>) -> Self { + Self { join } + } + + /// Blocks the caller until the actor task completes, propagating any error + /// returned by the actor's run loop. + pub async fn run_until_done(self) -> color_eyre::Result<()> { + self.join.await? + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 0697731..653e011 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,5 +1,7 @@ +pub mod actor; pub mod commands; pub mod errors; +pub mod handle; pub mod input; pub mod messages; pub mod popup; @@ -9,6 +11,7 @@ pub mod transitions; pub mod updates; pub mod view_model; + use color_eyre::eyre::{bail, eyre}; use tracing::{event, Level}; diff --git a/src/app/updates.rs b/src/app/updates.rs index a382b68..a4c34f2 100644 --- a/src/app/updates.rs +++ b/src/app/updates.rs @@ -7,7 +7,7 @@ impl App { /// Processes app-driven updates that are not direct user input. pub async fn process_system_updates( &mut self, - loading: &mut dyn LoadingIndicator, + loading: &mut (dyn LoadingIndicator + Send), ) -> color_eyre::Result<()> { match self.state.navigation.current_screen { CurrentScreen::MailingListSelection => { diff --git a/src/handler/mod.rs b/src/handler/mod.rs index 01022c3..12bc227 100644 --- a/src/handler/mod.rs +++ b/src/handler/mod.rs @@ -30,7 +30,7 @@ use mail_list::handle_mailing_list_selection; const LOADING_FRAME_INTERVAL: Duration = Duration::from_millis(200); -pub(crate) trait LoadingIndicator { +pub(crate) trait LoadingIndicator: Send { fn start(&mut self, title: String); fn stop(&mut self) -> color_eyre::Result<()>; } @@ -137,7 +137,7 @@ async fn input_handling( Ok(ControlFlow::Continue(())) } -pub async fn run_app( +pub(crate) async fn run_app( mut app: App, terminal_handle: TerminalHandle, ui_handle: UiHandle, diff --git a/src/main.rs b/src/main.rs index 527aca8..625575a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,12 +11,11 @@ mod render_prefs; mod terminal; mod ui; -use app::App; +use app::{actor::AppActor, App}; use clap::Parser; use cli::Cli; use color_eyre::eyre::{bail, eyre}; use config::{ConfigService, ConfigServiceApi, ConfigSnapshot}; -use handler::run_app; use infrastructure::{ env::{EnvTrait, OsEnv}, file_system::OsFileSystem, @@ -185,13 +184,14 @@ async fn main() -> color_eyre::Result<()> { .await .map_err(|e| eyre!("{e}"))?; - run_app( + AppActor::spawn( app, terminal_handle.clone(), ui_handle.clone(), input_handle, app_input_rx, ) + .run_until_done() .await?; ui_handle.shutdown().await; terminal_handle From a831686ee408f6c575418656d2838d8756ef30e1 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 13:05:34 -0300 Subject: [PATCH 3/6] refactor(app): move screen flows into app and remove handler module This commit relocates screen-handling and loading logic under the app boundary and inlines the former run_app loop inside AppActor. The handler module is removed so application orchestration lives entirely within the app crate, with per-screen flows dispatched from the actor's input path. This commit is part of the architecture's refactoring phase 12. Signed-off-by: lorenzoberts --- src/app/actor.rs | 101 +++++++-- src/{handler => app/flows}/bookmarked.rs | 3 +- src/{handler => app/flows}/details_actions.rs | 0 src/{handler => app/flows}/edit_config.rs | 0 src/{handler => app/flows}/latest.rs | 3 +- src/{handler => app/flows}/mail_list.rs | 3 +- src/app/flows/mod.rs | 5 + src/app/loading.rs | 106 ++++++++++ src/app/mod.rs | 2 + src/app/updates.rs | 5 +- src/handler/mod.rs | 195 ------------------ src/main.rs | 1 - 12 files changed, 203 insertions(+), 221 deletions(-) rename src/{handler => app/flows}/bookmarked.rs (96%) rename src/{handler => app/flows}/details_actions.rs (100%) rename src/{handler => app/flows}/edit_config.rs (100%) rename src/{handler => app/flows}/latest.rs (97%) rename src/{handler => app/flows}/mail_list.rs (97%) create mode 100644 src/app/flows/mod.rs create mode 100644 src/app/loading.rs delete mode 100644 src/handler/mod.rs diff --git a/src/app/actor.rs b/src/app/actor.rs index aea645e..0383678 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -1,19 +1,30 @@ +use std::ops::ControlFlow; + use tokio::sync::mpsc; use crate::{ - app::{handle::AppHandle, App}, - handler, + app::{ + flows::{ + bookmarked::handle_bookmarked_patchsets, + details_actions::handle_patchset_details, + edit_config::handle_edit_config, + latest::handle_latest_patchsets, + mail_list::handle_mailing_list_selection, + }, + handle::AppHandle, + loading::{terminal_error, TerminalLoadingIndicator}, + screens::CurrentScreen, + App, + }, input::{event::InputEvent, handle::InputHandle}, - terminal::handle::TerminalHandle, + terminal::{handle::TerminalHandle, messages::TerminalFrame}, ui::handle::UiHandle, }; /// Owns `App` state and drives the main application loop on a dedicated task. /// /// Constructed via [`AppActor::spawn`], which moves all owned resources into -/// the actor and returns an [`AppHandle`] to the caller. The run loop itself -/// is identical to the former `handler::run_app` function; the move into an -/// actor boundary is the only change in this commit. +/// the actor and returns an [`AppHandle`] to the caller. pub struct AppActor { app: App, terminal_handle: TerminalHandle, @@ -42,14 +53,74 @@ impl AppActor { AppHandle::new(tokio::spawn(actor.run())) } - async fn run(self) -> color_eyre::Result<()> { - handler::run_app( - self.app, - self.terminal_handle, - self.ui_handle, - self.input_handle, - self.event_rx, - ) - .await + async fn run(mut self) -> color_eyre::Result<()> { + let mut loading = TerminalLoadingIndicator::new(self.terminal_handle.clone()); + + loop { + self.app.process_system_updates(&mut loading).await?; + + let scene = self + .ui_handle + .build_scene(self.app.present()) + .await + .map_err(|e| color_eyre::eyre::eyre!("{e}"))?; + self.terminal_handle + .draw(TerminalFrame::Main(Box::new(scene))) + .await + .map_err(terminal_error)?; + + match self.event_rx.recv().await { + Some(input) => { + match on_input(&mut self.app, input, &self.terminal_handle, &mut loading) + .await? + { + ControlFlow::Continue(()) => {} + ControlFlow::Break(()) => return Ok(()), + } + self.input_handle + .update_context(self.app.input_context()) + .await + .ok(); + } + None => return Ok(()), + } + } + } +} + +async fn on_input( + app: &mut App, + input: InputEvent, + terminal_handle: &TerminalHandle, + loading: &mut TerminalLoadingIndicator, +) -> color_eyre::Result> { + if let Some(popup) = app.state.popup.as_mut() { + if input == InputEvent::ClosePopup { + app.state.popup = None; + } else { + popup.handle_scroll(input); + } + } else { + match app.state.navigation.current_screen { + CurrentScreen::MailingListSelection => { + match handle_mailing_list_selection(app, input, loading).await? { + ControlFlow::Continue(()) => {} + ControlFlow::Break(()) => return Ok(ControlFlow::Break(())), + } + } + CurrentScreen::BookmarkedPatchsets => { + handle_bookmarked_patchsets(app, input, loading).await?; + } + CurrentScreen::PatchsetDetails => { + handle_patchset_details(app, input, terminal_handle).await?; + } + CurrentScreen::EditConfig => { + handle_edit_config(app, input)?; + } + CurrentScreen::LatestPatchsets => { + handle_latest_patchsets(app, input, loading).await?; + } + } } + Ok(ControlFlow::Continue(())) } diff --git a/src/handler/bookmarked.rs b/src/app/flows/bookmarked.rs similarity index 96% rename from src/handler/bookmarked.rs rename to src/app/flows/bookmarked.rs index 47f46ee..f647be2 100644 --- a/src/handler/bookmarked.rs +++ b/src/app/flows/bookmarked.rs @@ -1,6 +1,5 @@ use crate::{ - app::{popup::AppPopup, screens::CurrentScreen, App, B4Result}, - handler::LoadingIndicator, + app::{loading::LoadingIndicator, popup::AppPopup, screens::CurrentScreen, App, B4Result}, input::event::InputEvent, }; diff --git a/src/handler/details_actions.rs b/src/app/flows/details_actions.rs similarity index 100% rename from src/handler/details_actions.rs rename to src/app/flows/details_actions.rs diff --git a/src/handler/edit_config.rs b/src/app/flows/edit_config.rs similarity index 100% rename from src/handler/edit_config.rs rename to src/app/flows/edit_config.rs diff --git a/src/handler/latest.rs b/src/app/flows/latest.rs similarity index 97% rename from src/handler/latest.rs rename to src/app/flows/latest.rs index 48eda23..12bd563 100644 --- a/src/handler/latest.rs +++ b/src/app/flows/latest.rs @@ -1,6 +1,5 @@ use crate::{ - app::{popup::AppPopup, screens::CurrentScreen, App, B4Result}, - handler::LoadingIndicator, + app::{loading::LoadingIndicator, popup::AppPopup, screens::CurrentScreen, App, B4Result}, input::event::InputEvent, }; diff --git a/src/handler/mail_list.rs b/src/app/flows/mail_list.rs similarity index 97% rename from src/handler/mail_list.rs rename to src/app/flows/mail_list.rs index ec67c29..277f1f3 100644 --- a/src/handler/mail_list.rs +++ b/src/app/flows/mail_list.rs @@ -1,8 +1,7 @@ use std::ops::ControlFlow; use crate::{ - app::{popup::AppPopup, screens::CurrentScreen, App}, - handler::LoadingIndicator, + app::{loading::LoadingIndicator, popup::AppPopup, screens::CurrentScreen, App}, input::event::InputEvent, }; diff --git a/src/app/flows/mod.rs b/src/app/flows/mod.rs new file mode 100644 index 0000000..fdc72d8 --- /dev/null +++ b/src/app/flows/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod bookmarked; +pub(crate) mod details_actions; +pub(crate) mod edit_config; +pub(crate) mod latest; +pub(crate) mod mail_list; diff --git a/src/app/loading.rs b/src/app/loading.rs new file mode 100644 index 0000000..0cb45d7 --- /dev/null +++ b/src/app/loading.rs @@ -0,0 +1,106 @@ +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + time::Duration, +}; + +use tokio::task::JoinHandle; + +use crate::terminal::{handle::TerminalHandle, messages::TerminalFrame, TerminalError}; + +pub(crate) const LOADING_FRAME_INTERVAL: Duration = Duration::from_millis(200); + +pub(crate) trait LoadingIndicator: Send { + fn start(&mut self, title: String); + fn stop(&mut self) -> color_eyre::Result<()>; +} + +pub(crate) struct TerminalLoadingIndicator { + terminal_handle: TerminalHandle, + running: Option>, + spinner_task: Option>, +} + +impl TerminalLoadingIndicator { + pub(crate) fn new(terminal_handle: TerminalHandle) -> Self { + Self { + terminal_handle, + running: None, + spinner_task: None, + } + } +} + +impl LoadingIndicator for TerminalLoadingIndicator { + fn start(&mut self, title: String) { + if self.spinner_task.is_some() { + return; + } + + let running = Arc::new(AtomicBool::new(true)); + let running_clone = Arc::clone(&running); + let terminal_handle = self.terminal_handle.clone(); + + self.running = Some(running); + self.spinner_task = Some(tokio::spawn(async move { + while running_clone.load(Ordering::Relaxed) { + if terminal_handle + .draw(TerminalFrame::Loading(title.clone())) + .await + .is_err() + { + break; + } + + std::thread::sleep(LOADING_FRAME_INTERVAL); + } + })); + + std::thread::sleep(LOADING_FRAME_INTERVAL); + } + + fn stop(&mut self) -> color_eyre::Result<()> { + let Some(spinner_task) = self.spinner_task.take() else { + return Ok(()); + }; + + if let Some(running) = self.running.take() { + running.store(false, Ordering::Relaxed); + } + + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { spinner_task.await.ok() }); + }); + + Ok(()) + } +} + +pub(crate) fn terminal_error(error: TerminalError) -> color_eyre::Report { + color_eyre::eyre::eyre!("{error}") +} + +#[cfg(test)] +mod tests { + use crate::terminal::{actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi}; + + use super::*; + + #[tokio::test(flavor = "multi_thread")] + async fn loading_indicator_draws_loading_frame_through_terminal_handle() { + let mut session = MockTerminalSessionApi::new(); + session + .expect_draw() + .withf(|frame| matches!(frame, TerminalFrame::Loading(_))) + .times(1..) + .returning(|_| Ok(())); + let handle = TerminalActor::spawn(Box::new(session)); + let mut loading = TerminalLoadingIndicator::new(handle); + + loading.start("Fetching mailing lists".to_string()); + std::thread::sleep(LOADING_FRAME_INTERVAL); + loading.stop().unwrap(); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 653e011..1ba3aa8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,8 +1,10 @@ pub mod actor; pub mod commands; pub mod errors; +pub(crate) mod flows; pub mod handle; pub mod input; +pub(crate) mod loading; pub mod messages; pub mod popup; pub mod screens; diff --git a/src/app/updates.rs b/src/app/updates.rs index a4c34f2..2084b08 100644 --- a/src/app/updates.rs +++ b/src/app/updates.rs @@ -1,7 +1,4 @@ -use crate::{ - app::{screens::CurrentScreen, App}, - handler::LoadingIndicator, -}; +use crate::app::{loading::LoadingIndicator, screens::CurrentScreen, App}; impl App { /// Processes app-driven updates that are not direct user input. diff --git a/src/handler/mod.rs b/src/handler/mod.rs deleted file mode 100644 index 12bc227..0000000 --- a/src/handler/mod.rs +++ /dev/null @@ -1,195 +0,0 @@ -mod bookmarked; -mod details_actions; -mod edit_config; -mod latest; -mod mail_list; - -use std::{ - ops::ControlFlow, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, - time::Duration, -}; - -use tokio::{sync::mpsc, task::JoinHandle}; - -use crate::{ - app::{screens::CurrentScreen, App}, - input::{event::InputEvent, handle::InputHandle}, - terminal::{handle::TerminalHandle, messages::TerminalFrame, TerminalError}, - ui::handle::UiHandle, -}; - -use bookmarked::handle_bookmarked_patchsets; -use details_actions::handle_patchset_details; -use edit_config::handle_edit_config; -use latest::handle_latest_patchsets; -use mail_list::handle_mailing_list_selection; - -const LOADING_FRAME_INTERVAL: Duration = Duration::from_millis(200); - -pub(crate) trait LoadingIndicator: Send { - fn start(&mut self, title: String); - fn stop(&mut self) -> color_eyre::Result<()>; -} - -struct TerminalLoadingIndicator { - terminal_handle: TerminalHandle, - running: Option>, - spinner_task: Option>, -} - -impl TerminalLoadingIndicator { - fn new(terminal_handle: TerminalHandle) -> Self { - Self { - terminal_handle, - running: None, - spinner_task: None, - } - } -} - -impl LoadingIndicator for TerminalLoadingIndicator { - fn start(&mut self, title: String) { - if self.spinner_task.is_some() { - return; - } - - let running = Arc::new(AtomicBool::new(true)); - let running_clone = Arc::clone(&running); - let terminal_handle = self.terminal_handle.clone(); - - self.running = Some(running); - self.spinner_task = Some(tokio::spawn(async move { - while running_clone.load(Ordering::Relaxed) { - if terminal_handle - .draw(TerminalFrame::Loading(title.clone())) - .await - .is_err() - { - break; - } - - std::thread::sleep(LOADING_FRAME_INTERVAL); - } - })); - - std::thread::sleep(LOADING_FRAME_INTERVAL); - } - - fn stop(&mut self) -> color_eyre::Result<()> { - let Some(spinner_task) = self.spinner_task.take() else { - return Ok(()); - }; - - if let Some(running) = self.running.take() { - running.store(false, Ordering::Relaxed); - } - - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(async { spinner_task.await.ok() }); - }); - - Ok(()) - } -} - -fn terminal_error(error: TerminalError) -> color_eyre::Report { - color_eyre::eyre::eyre!("{error}") -} - -async fn input_handling( - app: &mut App, - input: InputEvent, - terminal_handle: &TerminalHandle, - loading: &mut TerminalLoadingIndicator, -) -> color_eyre::Result> { - if let Some(popup) = app.state.popup.as_mut() { - if input == InputEvent::ClosePopup { - app.state.popup = None; - } else { - popup.handle_scroll(input); - } - } else { - match app.state.navigation.current_screen { - CurrentScreen::MailingListSelection => { - match handle_mailing_list_selection(app, input, loading).await? { - ControlFlow::Continue(()) => {} - ControlFlow::Break(()) => return Ok(ControlFlow::Break(())), - } - } - CurrentScreen::BookmarkedPatchsets => { - handle_bookmarked_patchsets(app, input, loading).await?; - } - CurrentScreen::PatchsetDetails => { - handle_patchset_details(app, input, terminal_handle).await?; - } - CurrentScreen::EditConfig => { - handle_edit_config(app, input)?; - } - CurrentScreen::LatestPatchsets => { - handle_latest_patchsets(app, input, loading).await?; - } - } - } - Ok(ControlFlow::Continue(())) -} - -pub(crate) async fn run_app( - mut app: App, - terminal_handle: TerminalHandle, - ui_handle: UiHandle, - input_handle: InputHandle, - mut app_input_rx: mpsc::Receiver, -) -> color_eyre::Result<()> { - let mut loading = TerminalLoadingIndicator::new(terminal_handle.clone()); - - loop { - app.process_system_updates(&mut loading).await?; - - let scene = ui_handle - .build_scene(app.present()) - .await - .map_err(|e| color_eyre::eyre::eyre!("{e}"))?; - terminal_handle - .draw(TerminalFrame::Main(Box::new(scene))) - .await - .map_err(terminal_error)?; - - match app_input_rx.recv().await { - Some(input) => { - match input_handling(&mut app, input, &terminal_handle, &mut loading).await? { - ControlFlow::Continue(()) => {} - ControlFlow::Break(()) => return Ok(()), - } - input_handle.update_context(app.input_context()).await.ok(); - } - None => return Ok(()), // InputActor stopped - } - } -} - -#[cfg(test)] -mod tests { - use crate::terminal::{actor::TerminalActor, session::MockTerminalSessionApi}; - - use super::*; - - #[tokio::test(flavor = "multi_thread")] - async fn loading_indicator_draws_loading_frame_through_terminal_handle() { - let mut session = MockTerminalSessionApi::new(); - session - .expect_draw() - .withf(|frame| matches!(frame, TerminalFrame::Loading(_))) - .times(1..) - .returning(|_| Ok(())); - let handle = TerminalActor::spawn(Box::new(session)); - let mut loading = TerminalLoadingIndicator::new(handle); - - loading.start("Fetching mailing lists".to_string()); - std::thread::sleep(LOADING_FRAME_INTERVAL); - loading.stop().unwrap(); - } -} diff --git a/src/main.rs b/src/main.rs index 625575a..0dfdc41 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ mod app; mod cli; mod config; -mod handler; mod infrastructure; mod input; mod lore; From a753973b0d012a7cfb658ef4be74f6a493033f9f Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 13:57:26 -0300 Subject: [PATCH 4/6] refactor(app): wire AppMessage channel and typed AppHandle This commit adds the mpsc control path between AppHandle and AppActor. The handle exposes shutdown, get_view_model, and get_input_context over the message protocol while input events continue through the existing subscriber channel, separating control queries from the hot input loop. This commit is part of the architecture's refactoring phase 12. Signed-off-by: lorenzoberts --- src/app/actor.rs | 174 ++++++++++++++++++++++++++++++++++++++++++---- src/app/handle.rs | 56 +++++++++++++-- 2 files changed, 209 insertions(+), 21 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index 0383678..cc9e7c6 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -13,6 +13,7 @@ use crate::{ }, handle::AppHandle, loading::{terminal_error, TerminalLoadingIndicator}, + messages::AppMessage, screens::CurrentScreen, App, }, @@ -21,6 +22,8 @@ use crate::{ ui::handle::UiHandle, }; +pub const DEFAULT_APP_MSG_CHANNEL_SIZE: usize = 32; + /// Owns `App` state and drives the main application loop on a dedicated task. /// /// Constructed via [`AppActor::spawn`], which moves all owned resources into @@ -31,11 +34,12 @@ pub struct AppActor { ui_handle: UiHandle, input_handle: InputHandle, event_rx: mpsc::Receiver, + msg_rx: mpsc::Receiver, } impl AppActor { /// Moves all resources into a new `AppActor`, spawns it on the Tokio - /// runtime, and returns an [`AppHandle`] to wait on its completion. + /// runtime, and returns an [`AppHandle`] to control and wait on it. pub fn spawn( app: App, terminal_handle: TerminalHandle, @@ -43,14 +47,16 @@ impl AppActor { input_handle: InputHandle, event_rx: mpsc::Receiver, ) -> AppHandle { + let (msg_tx, msg_rx) = mpsc::channel(DEFAULT_APP_MSG_CHANNEL_SIZE); let actor = Self { app, terminal_handle, ui_handle, input_handle, event_rx, + msg_rx, }; - AppHandle::new(tokio::spawn(actor.run())) + AppHandle::new(tokio::spawn(actor.run()), msg_tx) } async fn run(mut self) -> color_eyre::Result<()> { @@ -69,20 +75,35 @@ impl AppActor { .await .map_err(terminal_error)?; - match self.event_rx.recv().await { - Some(input) => { - match on_input(&mut self.app, input, &self.terminal_handle, &mut loading) - .await? - { - ControlFlow::Continue(()) => {} - ControlFlow::Break(()) => return Ok(()), + tokio::select! { + input = self.event_rx.recv() => match input { + Some(event) => { + match on_input(&mut self.app, event, &self.terminal_handle, &mut loading) + .await? + { + ControlFlow::Continue(()) => {} + ControlFlow::Break(()) => return Ok(()), + } + self.input_handle + .update_context(self.app.input_context()) + .await + .ok(); } - self.input_handle - .update_context(self.app.input_context()) - .await - .ok(); - } - None => return Ok(()), + None => return Ok(()), + }, + msg = self.msg_rx.recv() => match msg { + Some(AppMessage::Shutdown { reply_to }) => { + reply_to.send(()).ok(); + return Ok(()); + } + Some(AppMessage::GetViewModel { reply_to }) => { + reply_to.send(self.app.present()).ok(); + } + Some(AppMessage::GetInputContext { reply_to }) => { + reply_to.send(self.app.input_context()).ok(); + } + Some(AppMessage::Initialize) | Some(AppMessage::Input { .. }) | None => {} + }, } } } @@ -124,3 +145,126 @@ async fn on_input( } Ok(ControlFlow::Continue(())) } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use tokio::sync::mpsc; + + use crate::{ + app::{ + screens::{ + bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState, + CurrentScreen, + }, + state::{AppState, ConfigUiState, LoreUiState, NavigationState, UserLoreState}, + AppServices, + }, + config::ConfigState, + infrastructure::{ + env::MockEnvTrait, file_system::MockFileSystemTrait, shell::MockShellTrait, + }, + input::{handle::InputHandle, messages::InputMessage}, + lore::{application::handle::LoreApiHandle, domain::mailing_list::MailingList}, + render::handle::RenderHandle, + terminal::{actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi}, + ui::actor::UiActor, + }; + + use super::*; + + struct NullConfigService; + + impl crate::config::ConfigServiceApi for NullConfigService { + fn snapshot(&self) -> crate::config::ConfigSnapshot { + ConfigState::default().to_snapshot() + } + + fn validate_update( + &self, + _: crate::config::ConfigUpdateDraft, + ) -> Result { + unimplemented!() + } + + fn apply_update( + &mut self, + _: crate::config::ValidatedConfigUpdate, + ) -> Result { + unimplemented!() + } + } + + fn minimal_app() -> App { + let (lore_tx, _lore_rx) = mpsc::channel(1); + let (render_tx, _render_rx) = mpsc::channel(1); + + let dummy_list = MailingList::new("test-list", "Test list"); + + App { + state: AppState { + navigation: NavigationState { + current_screen: CurrentScreen::MailingListSelection, + }, + lore: LoreUiState { + mailing_list_selection: MailingListSelectionState { + mailing_lists: vec![dummy_list.clone()], + target_list: String::new(), + possible_mailing_lists: vec![dummy_list], + highlighted_list_index: 0, + }, + latest_patchsets: None, + details: None, + }, + user_state: UserLoreState { + bookmarked_patchsets: BookmarkedPatchsetsState { + bookmarked_patchsets: vec![], + patchset_index: 0, + }, + reviewed_patchsets: HashMap::new(), + }, + config_state: ConfigUiState { edit_config: None }, + config: ConfigState::default().to_snapshot(), + popup: None, + }, + services: AppServices { + lore_api: LoreApiHandle::new(lore_tx), + render: RenderHandle::new(render_tx), + shell: Box::new(MockShellTrait::new()), + fs: Box::new(MockFileSystemTrait::new()), + env: Box::new(MockEnvTrait::new()), + config: Box::new(NullConfigService), + }, + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn shutdown_message_stops_actor_and_returns_ok() { + let mut session = MockTerminalSessionApi::new(); + session + .expect_draw() + .withf(|frame| matches!(frame, TerminalFrame::Main(_))) + .times(1..) + .returning(|_| Ok(())); + + let terminal_handle = TerminalActor::spawn(Box::new(session)); + let ui_handle = UiActor::spawn(); + + let (_event_tx, event_rx) = mpsc::channel::(1); + let (input_tx, _input_rx) = mpsc::channel::(1); + let input_handle = InputHandle::new(input_tx); + + let handle = AppActor::spawn( + minimal_app(), + terminal_handle, + ui_handle, + input_handle, + event_rx, + ); + + handle.shutdown().await; + let result = handle.run_until_done().await; + assert!(result.is_ok()); + } +} diff --git a/src/app/handle.rs b/src/app/handle.rs index b41dd7c..bfe8aea 100644 --- a/src/app/handle.rs +++ b/src/app/handle.rs @@ -1,16 +1,48 @@ -use tokio::task::JoinHandle; +use tokio::{ + sync::{mpsc, oneshot}, + task::JoinHandle, +}; -/// Cloneable-free handle to the `AppActor` task. +use crate::{ + app::{messages::AppMessage, view_model::AppViewModel}, + input::context::InputContext, +}; + +#[allow(dead_code)] +/// Handle to the `AppActor` task. /// -/// The only operation available at this stage is waiting for the actor to -/// finish. A `Sender` and richer async methods are wired in a later commit. +/// Exposes typed async methods for controlling the actor and querying its +/// state. `run_until_done` consumes the handle and awaits the task's exit. pub struct AppHandle { join: JoinHandle>, + tx: mpsc::Sender, } +#[allow(dead_code)] impl AppHandle { - pub fn new(join: JoinHandle>) -> Self { - Self { join } + pub fn new(join: JoinHandle>, tx: mpsc::Sender) -> Self { + Self { join, tx } + } + + /// Requests the actor to stop its run loop and waits for acknowledgement. + pub async fn shutdown(&self) { + self.request(|reply_to| AppMessage::Shutdown { reply_to }) + .await + .ok(); + } + + /// Returns a snapshot of the current presentation model. + pub async fn get_view_model(&self) -> Option { + self.request(|reply_to| AppMessage::GetViewModel { reply_to }) + .await + .ok() + } + + /// Returns the current input context for the input mapper. + pub async fn get_input_context(&self) -> Option { + self.request(|reply_to| AppMessage::GetInputContext { reply_to }) + .await + .ok() } /// Blocks the caller until the actor task completes, propagating any error @@ -18,4 +50,16 @@ impl AppHandle { pub async fn run_until_done(self) -> color_eyre::Result<()> { self.join.await? } + + async fn request( + &self, + build_message: impl FnOnce(oneshot::Sender) -> AppMessage, + ) -> Result + where + T: Send + 'static, + { + let (reply_to, rx) = oneshot::channel(); + self.tx.send(build_message(reply_to)).await.ok(); + rx.await + } } From 6a2ab0a860ce0ff5f3f51fc6b520ef0ba7862b9c Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 14:04:37 -0300 Subject: [PATCH 5/6] refactor(app): move startup dependency checks into AppActor This commit makes startup validation an AppActor concern. External dependency checks run at the beginning of the actor loop and surface failures as AppError through run_until_done, removing the ad hoc validation path from main. AppHandle::initialize remains available over the message protocol for explicit queries. This commit is part of the architecture's refactoring phase 12. Signed-off-by: lorenzoberts --- src/app/actor.rs | 104 ++++++++++++++++++++++++++++++++++++++++++-- src/app/handle.rs | 9 +++- src/app/messages.rs | 6 ++- src/main.rs | 63 ++------------------------- 4 files changed, 116 insertions(+), 66 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index cc9e7c6..29f23b6 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -1,9 +1,11 @@ use std::ops::ControlFlow; use tokio::sync::mpsc; +use tracing::{event, Level}; use crate::{ app::{ + errors::AppError, flows::{ bookmarked::handle_bookmarked_patchsets, details_actions::handle_patchset_details, @@ -18,6 +20,7 @@ use crate::{ App, }, input::{event::InputEvent, handle::InputHandle}, + render_prefs::PatchRenderer, terminal::{handle::TerminalHandle, messages::TerminalFrame}, ui::handle::UiHandle, }; @@ -59,7 +62,60 @@ impl AppActor { AppHandle::new(tokio::spawn(actor.run()), msg_tx) } + /// Verifies required and optional external binaries. + /// + /// A missing `b4` is a hard failure; all other missing binaries only emit + /// warnings. This replicates the former `check_external_deps` free function + /// that lived in `main.rs`. + fn initialize(&self) -> Result<(), AppError> { + let env = &*self.app.services.env; + let config = &self.app.state.config; + + if !env.which("b4") { + event!(Level::ERROR, "b4 is not installed, patchsets cannot be downloaded"); + return Err(AppError::Dependencies( + "b4 is not installed; patchsets cannot be downloaded".to_string(), + )); + } + + if !env.which("git") { + event!(Level::WARN, "git is not installed, send-email won't work"); + } + + match config.patch_renderer() { + PatchRenderer::Bat => { + if !env.which("bat") { + event!( + Level::WARN, + "bat is not installed, patch rendering will fallback to default" + ); + } + } + PatchRenderer::Delta => { + if !env.which("delta") { + event!( + Level::WARN, + "delta is not installed, patch rendering will fallback to default", + ); + } + } + PatchRenderer::DiffSoFancy => { + if !env.which("diff-so-fancy") { + event!( + Level::WARN, + "diff-so-fancy is not installed, patch rendering will fallback to default", + ); + } + } + _ => {} + } + + Ok(()) + } + async fn run(mut self) -> color_eyre::Result<()> { + self.initialize()?; + let mut loading = TerminalLoadingIndicator::new(self.terminal_handle.clone()); loop { @@ -96,13 +152,16 @@ impl AppActor { reply_to.send(()).ok(); return Ok(()); } + Some(AppMessage::Initialize { reply_to }) => { + reply_to.send(self.initialize()).ok(); + } Some(AppMessage::GetViewModel { reply_to }) => { reply_to.send(self.app.present()).ok(); } Some(AppMessage::GetInputContext { reply_to }) => { reply_to.send(self.app.input_context()).ok(); } - Some(AppMessage::Initialize) | Some(AppMessage::Input { .. }) | None => {} + Some(AppMessage::Input { .. }) | None => {} }, } } @@ -154,6 +213,7 @@ mod tests { use crate::{ app::{ + errors::AppError, screens::{ bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState, CurrentScreen, @@ -196,7 +256,7 @@ mod tests { } } - fn minimal_app() -> App { + fn minimal_app_with_env(env: MockEnvTrait) -> App { let (lore_tx, _lore_rx) = mpsc::channel(1); let (render_tx, _render_rx) = mpsc::channel(1); @@ -233,12 +293,18 @@ mod tests { render: RenderHandle::new(render_tx), shell: Box::new(MockShellTrait::new()), fs: Box::new(MockFileSystemTrait::new()), - env: Box::new(MockEnvTrait::new()), + env: Box::new(env), config: Box::new(NullConfigService), }, } } + fn minimal_app() -> App { + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| true); + minimal_app_with_env(env) + } + #[tokio::test(flavor = "multi_thread")] async fn shutdown_message_stops_actor_and_returns_ok() { let mut session = MockTerminalSessionApi::new(); @@ -267,4 +333,36 @@ mod tests { let result = handle.run_until_done().await; assert!(result.is_ok()); } + + #[tokio::test(flavor = "multi_thread")] + async fn missing_b4_causes_actor_to_return_dependencies_error() { + let mut env = MockEnvTrait::new(); + env.expect_which() + .withf(|name| name == "b4") + .returning(|_| false); + + let mut session = MockTerminalSessionApi::new(); + session.expect_draw().returning(|_| Ok(())); + + let terminal_handle = TerminalActor::spawn(Box::new(session)); + let ui_handle = UiActor::spawn(); + + let (_event_tx, event_rx) = mpsc::channel::(1); + let (input_tx, _input_rx) = mpsc::channel::(1); + let input_handle = InputHandle::new(input_tx); + + let handle = AppActor::spawn( + minimal_app_with_env(env), + terminal_handle, + ui_handle, + input_handle, + event_rx, + ); + + let result = handle.run_until_done().await; + let err = result.unwrap_err(); + assert!(err + .downcast_ref::() + .is_some_and(|e| matches!(e, AppError::Dependencies(_)))); + } } diff --git a/src/app/handle.rs b/src/app/handle.rs index bfe8aea..a0a0ae1 100644 --- a/src/app/handle.rs +++ b/src/app/handle.rs @@ -4,7 +4,7 @@ use tokio::{ }; use crate::{ - app::{messages::AppMessage, view_model::AppViewModel}, + app::{errors::AppError, messages::AppMessage, view_model::AppViewModel}, input::context::InputContext, }; @@ -24,6 +24,13 @@ impl AppHandle { Self { join, tx } } + /// Requests the actor to run startup validation and returns the result. + pub async fn initialize(&self) -> Result<(), AppError> { + self.request(|reply_to| AppMessage::Initialize { reply_to }) + .await + .unwrap_or(Err(AppError::Input("actor channel closed".to_string()))) + } + /// Requests the actor to stop its run loop and waits for acknowledgement. pub async fn shutdown(&self) { self.request(|reply_to| AppMessage::Shutdown { reply_to }) diff --git a/src/app/messages.rs b/src/app/messages.rs index 5dc28c6..3caa020 100644 --- a/src/app/messages.rs +++ b/src/app/messages.rs @@ -3,7 +3,7 @@ use std::ops::ControlFlow; use tokio::sync::oneshot; use crate::{ - app::view_model::AppViewModel, + app::{errors::AppError, view_model::AppViewModel}, input::{context::InputContext, event::InputEvent}, }; @@ -14,7 +14,9 @@ use crate::{ /// variants rather than touching `App` state directly. pub enum AppMessage { /// Request the actor to perform startup validation. - Initialize, + Initialize { + reply_to: oneshot::Sender>, + }, /// Inject a synthetic input event for processing. Input { diff --git a/src/main.rs b/src/main.rs index 0dfdc41..d76e073 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,10 +13,10 @@ mod ui; use app::{actor::AppActor, App}; use clap::Parser; use cli::Cli; -use color_eyre::eyre::{bail, eyre}; -use config::{ConfigService, ConfigServiceApi, ConfigSnapshot}; +use color_eyre::eyre::eyre; +use config::{ConfigService, ConfigServiceApi}; use infrastructure::{ - env::{EnvTrait, OsEnv}, + env::OsEnv, file_system::OsFileSystem, monitoring::{init_monitoring, InitMonitoringProduct}, net::UreqNetClient, @@ -34,61 +34,12 @@ use lore::{ }, }; use render::{actor::RenderActor, ShellRenderService}; -use render_prefs::PatchRenderer; use std::{ops::ControlFlow, sync::Arc}; use terminal::{actor::TerminalActor, session::CrosstermTerminalSession}; use tokio::sync::mpsc; use tracing::{event, Level}; use ui::actor::UiActor; -/// Verifies required and optional external binaries before the TUI runs. -/// -/// Soft dependencies only emit warnings; a missing `b4` makes the app refuse to start. -fn check_external_deps(env: &dyn EnvTrait, config: &ConfigSnapshot) -> bool { - let mut app_can_run = true; - - if !env.which("b4") { - event!( - Level::ERROR, - "b4 is not installed, patchsets cannot be downloaded" - ); - app_can_run = false; - } - - if !env.which("git") { - event!(Level::WARN, "git is not installed, send-email won't work"); - } - - match config.patch_renderer() { - PatchRenderer::Bat => { - if !env.which("bat") { - event!( - Level::WARN, - "bat is not installed, patch rendering will fallback to default" - ); - } - } - PatchRenderer::Delta => { - if !env.which("delta") { - event!( - Level::WARN, - "delta is not installed, patch rendering will fallback to default", - ); - } - } - PatchRenderer::DiffSoFancy => { - if !env.which("diff-so-fancy") { - event!( - Level::WARN, - "diff-so-fancy is not installed, patch rendering will fallback to default", - ); - } - } - _ => {} - } - - app_can_run -} #[tokio::main] async fn main() -> color_eyre::Result<()> { @@ -168,14 +119,6 @@ async fn main() -> color_eyre::Result<()> { lore_api, render, )?; - if !check_external_deps(&*app.services.env, &app.state.config) { - event!( - Level::WARN, - "patch-hub cannot be executed because some dependencies are missing" - ); - bail!("patch-hub cannot be executed because some dependencies are missing, check logs for more information"); - } - let (app_input_tx, app_input_rx) = mpsc::channel::(64); let input_handle = InputActor::spawn(terminal_handle.clone(), app.input_context()); input_handle From bb2d17265726005e7e08c8d6fc0698b750d64efd Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 27 Jun 2026 14:09:22 -0300 Subject: [PATCH 6/6] refactor(app): add tracing to AppActor and application flows This commit adds structured tracing across AppActor lifecycle, message handling, and I/O-heavy flow branches without changing behavior. Actor start, stop, initialization failures, and per-screen dispatch are observable through the tracing subscriber. This commit completes the architecture's refactoring phase 12. Signed-off-by: lorenzoberts --- src/app/actor.rs | 103 ++++++++++++++++++++++--------- src/app/flows/bookmarked.rs | 3 + src/app/flows/details_actions.rs | 5 ++ src/app/flows/edit_config.rs | 3 + src/app/flows/latest.rs | 4 ++ src/app/flows/mail_list.rs | 4 ++ src/app/loading.rs | 4 +- src/app/messages.rs | 18 ++++-- src/app/mod.rs | 1 - src/main.rs | 1 - 10 files changed, 111 insertions(+), 35 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index 29f23b6..69475c0 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -7,10 +7,8 @@ use crate::{ app::{ errors::AppError, flows::{ - bookmarked::handle_bookmarked_patchsets, - details_actions::handle_patchset_details, - edit_config::handle_edit_config, - latest::handle_latest_patchsets, + bookmarked::handle_bookmarked_patchsets, details_actions::handle_patchset_details, + edit_config::handle_edit_config, latest::handle_latest_patchsets, mail_list::handle_mailing_list_selection, }, handle::AppHandle, @@ -50,6 +48,10 @@ impl AppActor { input_handle: InputHandle, event_rx: mpsc::Receiver, ) -> AppHandle { + tracing::debug!( + channel_size = DEFAULT_APP_MSG_CHANNEL_SIZE, + "spawning app actor" + ); let (msg_tx, msg_rx) = mpsc::channel(DEFAULT_APP_MSG_CHANNEL_SIZE); let actor = Self { app, @@ -72,7 +74,10 @@ impl AppActor { let config = &self.app.state.config; if !env.which("b4") { - event!(Level::ERROR, "b4 is not installed, patchsets cannot be downloaded"); + event!( + Level::ERROR, + "b4 is not installed, patchsets cannot be downloaded" + ); return Err(AppError::Dependencies( "b4 is not installed; patchsets cannot be downloaded".to_string(), )); @@ -114,7 +119,14 @@ impl AppActor { } async fn run(mut self) -> color_eyre::Result<()> { - self.initialize()?; + tracing::info!("app actor started"); + + let init_result = self.initialize(); + if let Err(ref e) = init_result { + tracing::warn!(error = %e, "app actor initialization failed"); + } + init_result?; + tracing::info!("app actor initialized"); let mut loading = TerminalLoadingIndicator::new(self.terminal_handle.clone()); @@ -131,40 +143,72 @@ impl AppActor { .await .map_err(terminal_error)?; - tokio::select! { + enum Outcome { + Continue, + UpdateContext, + Stop, + } + + let outcome = tokio::select! { input = self.event_rx.recv() => match input { Some(event) => { match on_input(&mut self.app, event, &self.terminal_handle, &mut loading) .await? { - ControlFlow::Continue(()) => {} - ControlFlow::Break(()) => return Ok(()), + ControlFlow::Continue(()) => Outcome::UpdateContext, + ControlFlow::Break(()) => Outcome::Stop, } - self.input_handle - .update_context(self.app.input_context()) - .await - .ok(); } - None => return Ok(()), + None => { + tracing::info!("input channel closed; app actor stopping"); + Outcome::Stop + } }, msg = self.msg_rx.recv() => match msg { - Some(AppMessage::Shutdown { reply_to }) => { - reply_to.send(()).ok(); - return Ok(()); - } - Some(AppMessage::Initialize { reply_to }) => { - reply_to.send(self.initialize()).ok(); - } - Some(AppMessage::GetViewModel { reply_to }) => { - reply_to.send(self.app.present()).ok(); - } - Some(AppMessage::GetInputContext { reply_to }) => { - reply_to.send(self.app.input_context()).ok(); + Some(ref m) => { + tracing::debug!(message = m.name(), "app message received"); + match msg { + Some(AppMessage::Shutdown { reply_to }) => { + tracing::debug!("app actor shutting down"); + reply_to.send(()).ok(); + Outcome::Stop + } + Some(AppMessage::Initialize { reply_to }) => { + let result = self.initialize(); + if let Err(ref e) = result { + tracing::warn!(error = %e, "re-initialization failed"); + } + reply_to.send(result).ok(); + Outcome::Continue + } + Some(AppMessage::GetViewModel { reply_to }) => { + reply_to.send(self.app.present()).ok(); + Outcome::Continue + } + Some(AppMessage::GetInputContext { reply_to }) => { + reply_to.send(self.app.input_context()).ok(); + Outcome::Continue + } + Some(AppMessage::Input { .. }) | None => Outcome::Continue, + } } - Some(AppMessage::Input { .. }) | None => {} + None => Outcome::Continue, }, + }; + + match outcome { + Outcome::Stop => break, + Outcome::UpdateContext => { + self.input_handle + .update_context(self.app.input_context()) + .await + .ok(); + } + Outcome::Continue => {} } } + tracing::info!("app actor stopped"); + Ok(()) } } @@ -181,6 +225,7 @@ async fn on_input( popup.handle_scroll(input); } } else { + tracing::debug!(screen = ?app.state.navigation.current_screen, "dispatching input to screen handler"); match app.state.navigation.current_screen { CurrentScreen::MailingListSelection => { match handle_mailing_list_selection(app, input, loading).await? { @@ -228,7 +273,9 @@ mod tests { input::{handle::InputHandle, messages::InputMessage}, lore::{application::handle::LoreApiHandle, domain::mailing_list::MailingList}, render::handle::RenderHandle, - terminal::{actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi}, + terminal::{ + actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi, + }, ui::actor::UiActor, }; diff --git a/src/app/flows/bookmarked.rs b/src/app/flows/bookmarked.rs index f647be2..cfc2aef 100644 --- a/src/app/flows/bookmarked.rs +++ b/src/app/flows/bookmarked.rs @@ -1,3 +1,5 @@ +use tracing::debug; + use crate::{ app::{loading::LoadingIndicator, popup::AppPopup, screens::CurrentScreen, App, B4Result}, input::event::InputEvent, @@ -30,6 +32,7 @@ pub async fn handle_bookmarked_patchsets( .select_above_patchset(); } InputEvent::OpenPatchsetDetails => { + debug!("loading patchset details from bookmarks"); loading.start("Loading patchset".to_string()); let result = app.open_patchset_details().await; loading.stop()?; diff --git a/src/app/flows/details_actions.rs b/src/app/flows/details_actions.rs index 7b9cc5c..a022a37 100644 --- a/src/app/flows/details_actions.rs +++ b/src/app/flows/details_actions.rs @@ -1,6 +1,7 @@ use std::time::Duration; use ratatui::crossterm::event::KeyCode; +use tracing::debug; use crate::{ app::{popup::AppPopup, screens::CurrentScreen, App}, @@ -76,6 +77,10 @@ pub async fn handle_patchset_details( app.state.popup = Some(popup); } InputEvent::ConsolidatePatchsetActions => { + debug!( + requires_user_io = patchset_details_and_actions.actions_require_user_io(), + "consolidating patchset actions" + ); if patchset_details_and_actions.actions_require_user_io() { terminal_handle.setup_user_io().await?; app.consolidate_patchset_actions().await?; diff --git a/src/app/flows/edit_config.rs b/src/app/flows/edit_config.rs index bc3418e..0895486 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -1,3 +1,5 @@ +use tracing::debug; + use crate::{ app::{popup::AppPopup, screens::CurrentScreen, App}, input::event::InputEvent, @@ -30,6 +32,7 @@ pub fn handle_edit_config(app: &mut App, input: InputEvent) -> color_eyre::Resul app.state.popup = Some(popup); } InputEvent::SaveConfig => { + debug!("saving edited configuration"); app.consolidate_edit_config()?; app.reset_edit_config(); app.set_current_screen(CurrentScreen::MailingListSelection); diff --git a/src/app/flows/latest.rs b/src/app/flows/latest.rs index 12bd563..99ffbbb 100644 --- a/src/app/flows/latest.rs +++ b/src/app/flows/latest.rs @@ -1,3 +1,5 @@ +use tracing::debug; + use crate::{ app::{loading::LoadingIndicator, popup::AppPopup, screens::CurrentScreen, App, B4Result}, input::event::InputEvent, @@ -42,6 +44,7 @@ pub async fn handle_latest_patchsets( .unwrap() .target_list() .to_string(); + debug!(list = list_name, "fetching next page of patchsets"); loading.start(format!("Fetching patchsets from {list_name}")); app.state .lore @@ -64,6 +67,7 @@ pub async fn handle_latest_patchsets( app.fetch_latest_current_page().await?; } InputEvent::OpenPatchsetDetails => { + debug!("loading patchset details from latest"); loading.start("Loading patchset".to_string()); let result = app.open_patchset_details().await; loading.stop()?; diff --git a/src/app/flows/mail_list.rs b/src/app/flows/mail_list.rs index 277f1f3..aa0a743 100644 --- a/src/app/flows/mail_list.rs +++ b/src/app/flows/mail_list.rs @@ -5,6 +5,8 @@ use crate::{ input::event::InputEvent, }; +use tracing::debug; + pub async fn handle_mailing_list_selection( app: &mut App, input: InputEvent, @@ -32,6 +34,7 @@ pub async fn handle_mailing_list_selection( .target_list() .to_string(); + debug!(list = list_name, "fetching latest patchsets"); loading.start(format!("Fetching patchsets from {list_name}")); let result = app.fetch_latest_current_page().await; loading.stop()?; @@ -43,6 +46,7 @@ pub async fn handle_mailing_list_selection( } } InputEvent::RefreshMailingLists => { + debug!("refreshing available mailing lists"); loading.start("Refreshing lists".to_string()); let result = app.refresh_mailing_lists().await; loading.stop()?; diff --git a/src/app/loading.rs b/src/app/loading.rs index 0cb45d7..1f60a70 100644 --- a/src/app/loading.rs +++ b/src/app/loading.rs @@ -84,7 +84,9 @@ pub(crate) fn terminal_error(error: TerminalError) -> color_eyre::Report { #[cfg(test)] mod tests { - use crate::terminal::{actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi}; + use crate::terminal::{ + actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi, + }; use super::*; diff --git a/src/app/messages.rs b/src/app/messages.rs index 3caa020..50bb445 100644 --- a/src/app/messages.rs +++ b/src/app/messages.rs @@ -7,7 +7,6 @@ use crate::{ input::{context::InputContext, event::InputEvent}, }; -#[allow(dead_code)] /// Typed message protocol for the `AppActor`. /// /// External callers communicate with the actor exclusively through these @@ -19,6 +18,7 @@ pub enum AppMessage { }, /// Inject a synthetic input event for processing. + #[allow(dead_code)] Input { event: InputEvent, reply_to: oneshot::Sender>>, @@ -35,7 +35,17 @@ pub enum AppMessage { }, /// Request the actor to stop its run loop and exit cleanly. - Shutdown { - reply_to: oneshot::Sender<()>, - }, + Shutdown { reply_to: oneshot::Sender<()> }, +} + +impl AppMessage { + pub fn name(&self) -> &'static str { + match self { + AppMessage::Initialize { .. } => "Initialize", + AppMessage::Input { .. } => "Input", + AppMessage::GetViewModel { .. } => "GetViewModel", + AppMessage::GetInputContext { .. } => "GetInputContext", + AppMessage::Shutdown { .. } => "Shutdown", + } + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 1ba3aa8..78d1f00 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -13,7 +13,6 @@ pub mod transitions; pub mod updates; pub mod view_model; - use color_eyre::eyre::{bail, eyre}; use tracing::{event, Level}; diff --git a/src/main.rs b/src/main.rs index d76e073..7fd36ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,6 @@ use tokio::sync::mpsc; use tracing::{event, Level}; use ui::actor::UiActor; - #[tokio::main] async fn main() -> color_eyre::Result<()> { // file writer guards should be propagated to main() so the logging thread lives enough