diff --git a/src/app/actor.rs b/src/app/actor.rs new file mode 100644 index 0000000..69475c0 --- /dev/null +++ b/src/app/actor.rs @@ -0,0 +1,415 @@ +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, + edit_config::handle_edit_config, latest::handle_latest_patchsets, + mail_list::handle_mailing_list_selection, + }, + handle::AppHandle, + loading::{terminal_error, TerminalLoadingIndicator}, + messages::AppMessage, + screens::CurrentScreen, + App, + }, + input::{event::InputEvent, handle::InputHandle}, + render_prefs::PatchRenderer, + terminal::{handle::TerminalHandle, messages::TerminalFrame}, + 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 +/// the actor and returns an [`AppHandle`] to the caller. +pub struct AppActor { + app: App, + terminal_handle: TerminalHandle, + 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 control and wait on it. + pub fn spawn( + app: App, + terminal_handle: TerminalHandle, + ui_handle: UiHandle, + 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, + terminal_handle, + ui_handle, + input_handle, + event_rx, + msg_rx, + }; + 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<()> { + 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()); + + 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)?; + + 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(()) => Outcome::UpdateContext, + ControlFlow::Break(()) => Outcome::Stop, + } + } + None => { + tracing::info!("input channel closed; app actor stopping"); + Outcome::Stop + } + }, + msg = self.msg_rx.recv() => match msg { + 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, + } + } + 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(()) + } +} + +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 { + 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? { + 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(())) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use tokio::sync::mpsc; + + use crate::{ + app::{ + errors::AppError, + 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_with_env(env: MockEnvTrait) -> 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(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(); + 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()); + } + + #[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/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/handler/bookmarked.rs b/src/app/flows/bookmarked.rs similarity index 93% rename from src/handler/bookmarked.rs rename to src/app/flows/bookmarked.rs index 47f46ee..cfc2aef 100644 --- a/src/handler/bookmarked.rs +++ b/src/app/flows/bookmarked.rs @@ -1,6 +1,7 @@ +use tracing::debug; + use crate::{ - app::{popup::AppPopup, screens::CurrentScreen, App, B4Result}, - handler::LoadingIndicator, + app::{loading::LoadingIndicator, popup::AppPopup, screens::CurrentScreen, App, B4Result}, input::event::InputEvent, }; @@ -31,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/handler/details_actions.rs b/src/app/flows/details_actions.rs similarity index 96% rename from src/handler/details_actions.rs rename to src/app/flows/details_actions.rs index 7b9cc5c..a022a37 100644 --- a/src/handler/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/handler/edit_config.rs b/src/app/flows/edit_config.rs similarity index 96% rename from src/handler/edit_config.rs rename to src/app/flows/edit_config.rs index bc3418e..0895486 100644 --- a/src/handler/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/handler/latest.rs b/src/app/flows/latest.rs similarity index 93% rename from src/handler/latest.rs rename to src/app/flows/latest.rs index 48eda23..99ffbbb 100644 --- a/src/handler/latest.rs +++ b/src/app/flows/latest.rs @@ -1,6 +1,7 @@ +use tracing::debug; + use crate::{ - app::{popup::AppPopup, screens::CurrentScreen, App, B4Result}, - handler::LoadingIndicator, + app::{loading::LoadingIndicator, popup::AppPopup, screens::CurrentScreen, App, B4Result}, input::event::InputEvent, }; @@ -43,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 @@ -65,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/handler/mail_list.rs b/src/app/flows/mail_list.rs similarity index 93% rename from src/handler/mail_list.rs rename to src/app/flows/mail_list.rs index ec67c29..aa0a743 100644 --- a/src/handler/mail_list.rs +++ b/src/app/flows/mail_list.rs @@ -1,11 +1,12 @@ 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, }; +use tracing::debug; + pub async fn handle_mailing_list_selection( app: &mut App, input: InputEvent, @@ -33,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()?; @@ -44,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/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/handle.rs b/src/app/handle.rs new file mode 100644 index 0000000..a0a0ae1 --- /dev/null +++ b/src/app/handle.rs @@ -0,0 +1,72 @@ +use tokio::{ + sync::{mpsc, oneshot}, + task::JoinHandle, +}; + +use crate::{ + app::{errors::AppError, messages::AppMessage, view_model::AppViewModel}, + input::context::InputContext, +}; + +#[allow(dead_code)] +/// Handle to the `AppActor` task. +/// +/// 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>, tx: mpsc::Sender) -> Self { + 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 }) + .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 + /// returned by the actor's run loop. + 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 + } +} diff --git a/src/app/loading.rs b/src/app/loading.rs new file mode 100644 index 0000000..1f60a70 --- /dev/null +++ b/src/app/loading.rs @@ -0,0 +1,108 @@ +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/messages.rs b/src/app/messages.rs new file mode 100644 index 0000000..50bb445 --- /dev/null +++ b/src/app/messages.rs @@ -0,0 +1,51 @@ +use std::ops::ControlFlow; + +use tokio::sync::oneshot; + +use crate::{ + app::{errors::AppError, view_model::AppViewModel}, + input::{context::InputContext, event::InputEvent}, +}; + +/// 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 { + reply_to: oneshot::Sender>, + }, + + /// Inject a synthetic input event for processing. + #[allow(dead_code)] + 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<()> }, +} + +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 e8f05f3..78d1f00 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,9 +1,15 @@ +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; 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, +} diff --git a/src/app/updates.rs b/src/app/updates.rs index a382b68..2084b08 100644 --- a/src/app/updates.rs +++ b/src/app/updates.rs @@ -1,13 +1,10 @@ -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. 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 deleted file mode 100644 index 01022c3..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 { - 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 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 527aca8..7fd36ff 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; @@ -11,14 +10,13 @@ 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 color_eyre::eyre::eyre; +use config::{ConfigService, ConfigServiceApi}; use infrastructure::{ - env::{EnvTrait, OsEnv}, + env::OsEnv, file_system::OsFileSystem, monitoring::{init_monitoring, InitMonitoringProduct}, net::UreqNetClient, @@ -36,62 +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<()> { // file writer guards should be propagated to main() so the logging thread lives enough @@ -170,14 +118,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 @@ -185,13 +125,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