Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
415 changes: 415 additions & 0 deletions src/app/actor.rs

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions src/app/errors.rs
Original file line number Diff line number Diff line change
@@ -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}")]
Expand All @@ -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),
}
6 changes: 4 additions & 2 deletions src/handler/bookmarked.rs → src/app/flows/bookmarked.rs
Original file line number Diff line number Diff line change
@@ -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,
};

Expand Down Expand Up @@ -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()?;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::time::Duration;

use ratatui::crossterm::event::KeyCode;
use tracing::debug;

use crate::{
app::{popup::AppPopup, screens::CurrentScreen, App},
Expand Down Expand Up @@ -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?;
Expand Down
3 changes: 3 additions & 0 deletions src/handler/edit_config.rs → src/app/flows/edit_config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use tracing::debug;

use crate::{
app::{popup::AppPopup, screens::CurrentScreen, App},
input::event::InputEvent,
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions src/handler/latest.rs → src/app/flows/latest.rs
Original file line number Diff line number Diff line change
@@ -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,
};

Expand Down Expand Up @@ -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
Expand All @@ -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()?;
Expand Down
7 changes: 5 additions & 2 deletions src/handler/mail_list.rs → src/app/flows/mail_list.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()?;
Expand All @@ -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()?;
Expand Down
5 changes: 5 additions & 0 deletions src/app/flows/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
72 changes: 72 additions & 0 deletions src/app/handle.rs
Original file line number Diff line number Diff line change
@@ -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<color_eyre::Result<()>>,
tx: mpsc::Sender<AppMessage>,
}

#[allow(dead_code)]
impl AppHandle {
pub fn new(join: JoinHandle<color_eyre::Result<()>>, tx: mpsc::Sender<AppMessage>) -> 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<AppViewModel> {
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<InputContext> {
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<T>(
&self,
build_message: impl FnOnce(oneshot::Sender<T>) -> AppMessage,
) -> Result<T, oneshot::error::RecvError>
where
T: Send + 'static,
{
let (reply_to, rx) = oneshot::channel();
self.tx.send(build_message(reply_to)).await.ok();
rx.await
}
}
108 changes: 108 additions & 0 deletions src/app/loading.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<AtomicBool>>,
spinner_task: Option<JoinHandle<()>>,
}

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();
}
}
51 changes: 51 additions & 0 deletions src/app/messages.rs
Original file line number Diff line number Diff line change
@@ -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<Result<(), AppError>>,
},

/// Inject a synthetic input event for processing.
#[allow(dead_code)]
Input {
event: InputEvent,
reply_to: oneshot::Sender<color_eyre::Result<ControlFlow<()>>>,
},

/// Request a snapshot of the current presentation model.
GetViewModel {
reply_to: oneshot::Sender<AppViewModel>,
},

/// Request the current input context for the input mapper.
GetInputContext {
reply_to: oneshot::Sender<InputContext>,
},

/// 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",
}
}
}
6 changes: 6 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
Loading
Loading