WIP: feat(tui): update state model from server messages
All checks were successful
ci/woodpecker/push/cron-docker-builder Pipeline was successful
ci/woodpecker/push/push-next Pipeline was successful
ci/woodpecker/push/tag-created Pipeline was successful

This commit is contained in:
Paul Campbell 2024-08-12 21:25:24 +01:00
parent 622e144986
commit 50a7e9a3ec
20 changed files with 348 additions and 90 deletions

View file

@ -12,7 +12,8 @@ keywords = { workspace = true }
categories = { workspace = true } categories = { workspace = true }
[features] [features]
default = ["forgejo", "github"] # default = ["forgejo", "github"]
default = ["forgejo", "github", "tui"]
forgejo = ["git-next-forge-forgejo"] forgejo = ["git-next-forge-forgejo"]
github = ["git-next-forge-github"] github = ["git-next-forge-github"]
tui = ["ratatui"] tui = ["ratatui"]

View file

@ -2,10 +2,13 @@
use actix::prelude::*; use actix::prelude::*;
use tracing::instrument; use tracing::instrument;
use crate::repo::{ use crate::{
do_send, repo::{
messages::{ReceiveRepoConfig, RegisterWebhook}, do_send,
RepoActor, messages::{ReceiveRepoConfig, RegisterWebhook},
RepoActor,
},
server::actor::messages::ServerUpdate,
}; };
impl Handler<ReceiveRepoConfig> for RepoActor { impl Handler<ReceiveRepoConfig> for RepoActor {
@ -13,7 +16,14 @@ impl Handler<ReceiveRepoConfig> for RepoActor {
#[instrument(name = "RepoActor::ReceiveRepoConfig", skip_all, fields(repo = %self.repo_details, branches = ?msg))] #[instrument(name = "RepoActor::ReceiveRepoConfig", skip_all, fields(repo = %self.repo_details, branches = ?msg))]
fn handle(&mut self, msg: ReceiveRepoConfig, ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: ReceiveRepoConfig, ctx: &mut Self::Context) -> Self::Result {
let repo_config = msg.unwrap(); let repo_config = msg.unwrap();
self.repo_details.repo_config.replace(repo_config); self.repo_details.repo_config.replace(repo_config.clone());
if let Some(server_addr) = &self.server_addr {
server_addr.do_send(ServerUpdate::from((
&repo_config,
self.repo_details.forge.forge_alias().clone(),
self.repo_details.repo_alias.clone(),
)));
}
do_send(&ctx.address(), RegisterWebhook::new(), self.log.as_ref()); do_send(&ctx.address(), RegisterWebhook::new(), self.log.as_ref());
} }

View file

@ -53,6 +53,9 @@ impl Handler<ValidateRepo> for RepoActor {
}; };
logger(self.log.as_ref(), "have repo config"); logger(self.log.as_ref(), "have repo config");
#[cfg(feature = "tui")]
self.update_tui();
match validate(&**open_repository, &self.repo_details, &repo_config) { match validate(&**open_repository, &self.repo_details, &repo_config) {
Ok(Positions { Ok(Positions {
main, main,

View file

@ -135,7 +135,7 @@ fn handle_push(
last_commit: &mut Option<Commit>, last_commit: &mut Option<Commit>,
log: Option<&ActorLog>, log: Option<&ActorLog>,
) -> Result<(), ()> { ) -> Result<(), ()> {
logger(log, "message is for dev branch"); logger(log, format!("message is for {branch} branch"));
let commit = Commit::from(push); let commit = Commit::from(push);
if last_commit.as_ref() == Some(&commit) { if last_commit.as_ref() == Some(&commit) {
logger(log, format!("not a new commit on {branch}")); logger(log, format!("not a new commit on {branch}"));

View file

@ -1,7 +1,7 @@
// //
use actix::prelude::*; use actix::prelude::*;
use crate::alerts::messages::NotifyUser; use crate::{alerts::messages::NotifyUser, server::ServerActor};
use derive_more::Deref; use derive_more::Deref;
use kxio::network::Network; use kxio::network::Network;
use std::time::Duration; use std::time::Duration;
@ -59,6 +59,7 @@ pub struct RepoActor {
forge: Box<dyn git::ForgeLike>, forge: Box<dyn git::ForgeLike>,
log: Option<ActorLog>, log: Option<ActorLog>,
notify_user_recipient: Option<Recipient<NotifyUser>>, notify_user_recipient: Option<Recipient<NotifyUser>>,
server_addr: Option<Addr<ServerActor>>,
} }
impl RepoActor { impl RepoActor {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@ -71,6 +72,7 @@ impl RepoActor {
repository_factory: Box<dyn RepositoryFactory>, repository_factory: Box<dyn RepositoryFactory>,
sleep_duration: std::time::Duration, sleep_duration: std::time::Duration,
notify_user_recipient: Option<Recipient<NotifyUser>>, notify_user_recipient: Option<Recipient<NotifyUser>>,
server_addr: Option<Addr<ServerActor>>,
) -> Self { ) -> Self {
let message_token = messages::MessageToken::default(); let message_token = messages::MessageToken::default();
Self { Self {
@ -90,6 +92,18 @@ impl RepoActor {
sleep_duration, sleep_duration,
log: None, log: None,
notify_user_recipient, notify_user_recipient,
server_addr,
}
}
#[cfg(feature = "tui")]
fn update_tui(&self) {
use crate::server::actor::messages::ServerUpdate;
if let (Some(server_addr), Some(repo_config)) =
(&self.server_addr, &self.repo_details.repo_config)
{
server_addr.do_send(ServerUpdate::from((&self.repo_details, repo_config)));
} }
} }
} }

View file

@ -195,6 +195,7 @@ pub fn a_repo_actor(
repository_factory, repository_factory,
std::time::Duration::from_nanos(1), std::time::Duration::from_nanos(1),
None, None,
None,
) )
.with_log(actors_log), .with_log(actors_log),
log, log,

View file

@ -1,5 +1,6 @@
mod file_updated; mod file_updated;
mod receive_app_config; mod receive_app_config;
mod receive_valid_app_config; mod receive_valid_app_config;
mod server_update;
mod shutdown; mod shutdown;
mod subscribe_updates; mod subscribe_updates;

View file

@ -8,7 +8,7 @@ use crate::{
alerts::messages::UpdateShout, alerts::messages::UpdateShout,
repo::{messages::CloneRepo, RepoActor}, repo::{messages::CloneRepo, RepoActor},
server::actor::{ server::actor::{
messages::{ReceiveValidAppConfig, ValidAppConfig}, messages::{ReceiveValidAppConfig, ServerUpdate, ValidAppConfig},
ServerActor, ServerActor,
}, },
webhook::{ webhook::{
@ -21,7 +21,7 @@ use crate::{
impl Handler<ReceiveValidAppConfig> for ServerActor { impl Handler<ReceiveValidAppConfig> for ServerActor {
type Result = (); type Result = ();
fn handle(&mut self, msg: ReceiveValidAppConfig, _ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: ReceiveValidAppConfig, ctx: &mut Self::Context) -> Self::Result {
let ValidAppConfig { let ValidAppConfig {
app_config, app_config,
socket_address, socket_address,
@ -37,6 +37,7 @@ impl Handler<ReceiveValidAppConfig> for ServerActor {
let webhook_router = WebhookRouterActor::default().start(); let webhook_router = WebhookRouterActor::default().start();
let listen_url = app_config.listen().url(); let listen_url = app_config.listen().url();
let notify_user_recipient = self.alerts.clone().recipient(); let notify_user_recipient = self.alerts.clone().recipient();
let server_addr = Some(ctx.address());
// Forge Actors // Forge Actors
for (forge_alias, forge_config) in app_config.forges() { for (forge_alias, forge_config) in app_config.forges() {
let repo_actors = self let repo_actors = self
@ -46,6 +47,7 @@ impl Handler<ReceiveValidAppConfig> for ServerActor {
&server_storage, &server_storage,
listen_url, listen_url,
&notify_user_recipient, &notify_user_recipient,
server_addr.clone(),
) )
.into_iter() .into_iter()
.map(start_repo_actor) .map(start_repo_actor)
@ -69,9 +71,9 @@ impl Handler<ReceiveValidAppConfig> for ServerActor {
WebhookActor::new(socket_address, webhook_router.recipient()).start(); WebhookActor::new(socket_address, webhook_router.recipient()).start();
self.webhook_actor_addr.replace(webhook_actor_addr); self.webhook_actor_addr.replace(webhook_actor_addr);
let shout = app_config.shout().clone(); let shout = app_config.shout().clone();
ctx.notify(ServerUpdate::from(&app_config));
self.app_config.replace(app_config); self.app_config.replace(app_config);
self.alerts.do_send(UpdateShout::new(shout)); self.alerts.do_send(UpdateShout::new(shout));
self.send_server_updates();
} }
} }

View file

@ -0,0 +1,14 @@
use actix::Handler;
//
use crate::server::{actor::messages::ServerUpdate, ServerActor};
impl Handler<ServerUpdate> for ServerActor {
type Result = ();
fn handle(&mut self, msg: ServerUpdate, _ctx: &mut Self::Context) -> Self::Result {
self.subscribers.iter().for_each(move |subscriber| {
subscriber.do_send(msg.clone());
});
}
}

View file

@ -3,10 +3,10 @@ use actix::{Message, Recipient};
use derive_more::Constructor; use derive_more::Constructor;
use git_next_core::{ use git_next_core::{
git::graph::Log, git::{self, graph::Log, RepoDetails},
message, message,
server::{AppConfig, Storage}, server::{AppConfig, Storage},
ForgeAlias, RepoAlias, RepoBranches, ForgeAlias, RepoAlias, RepoBranches, RepoConfig,
}; };
use std::net::SocketAddr; use std::net::SocketAddr;
@ -40,20 +40,61 @@ message!(Shutdown, "Notification to shutdown the server actor");
#[derive(Clone, Debug, PartialEq, Eq, Message)] #[derive(Clone, Debug, PartialEq, Eq, Message)]
#[rtype(result = "()")] #[rtype(result = "()")]
pub enum ServerUpdate { pub enum ServerUpdate {
/// Status of a repo /// List of all configured forges and aliases
UpdateRepoSummary { ForgeRepoList { list: Vec<(ForgeAlias, RepoAlias)> },
/// Configuration of a repo
UpdateRepoConfig {
forge_alias: ForgeAlias,
repo_alias: RepoAlias,
branches: RepoBranches,
},
/// Status of the repo
UpdateRepoDetails {
forge_alias: ForgeAlias, forge_alias: ForgeAlias,
repo_alias: RepoAlias, repo_alias: RepoAlias,
branches: RepoBranches, branches: RepoBranches,
log: Log, log: Log,
}, },
/// remove a repo }
RemoveRepo {
forge_alias: ForgeAlias, impl From<&AppConfig> for ServerUpdate {
repo_alias: RepoAlias, fn from(app_config: &AppConfig) -> Self {
}, Self::ForgeRepoList {
/// test message list: app_config
Ping, .forges()
.flat_map(|(forge_alias, forge_config)| {
forge_config
.repos()
.map(|(repo_alias, _server_repo_config)| (forge_alias.clone(), repo_alias))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>(),
}
}
}
impl From<(&RepoConfig, ForgeAlias, RepoAlias)> for ServerUpdate {
fn from(value: (&RepoConfig, ForgeAlias, RepoAlias)) -> Self {
Self::UpdateRepoConfig {
forge_alias: value.1,
repo_alias: value.2,
branches: value.0.branches().clone(),
}
}
}
impl From<(&RepoDetails, &RepoConfig)> for ServerUpdate {
fn from((repo_details, repo_config): (&RepoDetails, &RepoConfig)) -> Self {
let branches = repo_config.branches().clone();
let git_graph_log = git::graph::log(repo_details);
Self::UpdateRepoDetails {
forge_alias: repo_details.forge.forge_alias().clone(),
repo_alias: repo_details.repo_alias.clone(),
branches,
log: git_graph_log,
}
}
} }
message!( message!(

View file

@ -118,6 +118,7 @@ impl ServerActor {
server_storage: &Storage, server_storage: &Storage,
listen_url: &ListenUrl, listen_url: &ListenUrl,
notify_user_recipient: &Recipient<NotifyUser>, notify_user_recipient: &Recipient<NotifyUser>,
server_addr: Option<Addr<Self>>,
) -> Vec<(ForgeAlias, RepoAlias, RepoActor)> { ) -> Vec<(ForgeAlias, RepoAlias, RepoActor)> {
let span = let span =
tracing::info_span!("create_forge_repos", name = %forge_name, config = %forge_config); tracing::info_span!("create_forge_repos", name = %forge_name, config = %forge_config);
@ -125,8 +126,13 @@ impl ServerActor {
let _guard = span.enter(); let _guard = span.enter();
tracing::info!("Creating Forge"); tracing::info!("Creating Forge");
let mut repos = vec![]; let mut repos = vec![];
let creator = let creator = self.create_actor(
self.create_actor(forge_name, forge_config.clone(), server_storage, listen_url); forge_name,
forge_config.clone(),
server_storage,
listen_url,
server_addr,
);
for (repo_alias, server_repo_config) in forge_config.repos() { for (repo_alias, server_repo_config) in forge_config.repos() {
let forge_repo = creator(( let forge_repo = creator((
repo_alias, repo_alias,
@ -148,6 +154,7 @@ impl ServerActor {
forge_config: ForgeConfig, forge_config: ForgeConfig,
server_storage: &Storage, server_storage: &Storage,
listen_url: &ListenUrl, listen_url: &ListenUrl,
server_addr: Option<Addr<Self>>,
) -> impl Fn( ) -> impl Fn(
(RepoAlias, &ServerRepoConfig, Recipient<NotifyUser>), (RepoAlias, &ServerRepoConfig, Recipient<NotifyUser>),
) -> (ForgeAlias, RepoAlias, RepoActor) { ) -> (ForgeAlias, RepoAlias, RepoActor) {
@ -194,6 +201,7 @@ impl ServerActor {
repository_factory.duplicate(), repository_factory.duplicate(),
sleep_duration, sleep_duration,
Some(notify_user_recipient), Some(notify_user_recipient),
server_addr.clone(),
); );
(forge_name.clone(), repo_alias, actor) (forge_name.clone(), repo_alias, actor)
} }
@ -242,10 +250,4 @@ impl ServerActor {
ctx.address().do_send(msg); ctx.address().do_send(msg);
} }
} }
fn send_server_updates(&self) {
self.subscribers.iter().for_each(|subscriber| {
subscriber.do_send(ServerUpdate::Ping);
});
}
} }

View file

@ -1,27 +1,54 @@
use std::time::Instant; use std::time::Instant;
use actix::Handler; use actix::Handler;
use git_next_core::git::graph::Log;
use crate::{server::actor::messages::ServerUpdate, tui::Tui}; use crate::{
server::actor::messages::ServerUpdate,
tui::{
actor::model::{ForgeRepoKey, RepoState, State},
Tui,
},
};
// //
impl Handler<ServerUpdate> for Tui { impl Handler<ServerUpdate> for Tui {
type Result = (); type Result = ();
fn handle(&mut self, msg: ServerUpdate, _ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: ServerUpdate, _ctx: &mut Self::Context) -> Self::Result {
self.last_update = Instant::now();
match msg { match msg {
ServerUpdate::UpdateRepoSummary { ServerUpdate::ForgeRepoList { list } => {
self.state = State::loaded(
list.into_iter()
.map(|(forge_alias, repo_alias)| ForgeRepoKey::new(forge_alias, repo_alias))
.map(|key| (key, RepoState::Loading))
.collect(),
);
}
ServerUpdate::UpdateRepoConfig {
forge_alias,
repo_alias,
branches,
} => {
self.state.repos_mut().insert(
ForgeRepoKey::new(forge_alias, repo_alias),
RepoState::Loaded {
branches,
log: Log::default(),
},
);
}
ServerUpdate::UpdateRepoDetails {
forge_alias, forge_alias,
repo_alias, repo_alias,
branches, branches,
log, log,
} => todo!(), } => {
ServerUpdate::RemoveRepo { self.state.repos_mut().insert(
forge_alias, ForgeRepoKey::new(forge_alias, repo_alias),
repo_alias, RepoState::Loaded { branches, log },
} => todo!(), );
ServerUpdate::Ping => {
self.last_ping = Instant::now();
} }
} }
} }

View file

@ -1,12 +1,5 @@
// //
use std::{borrow::BorrowMut, time::Instant}; use actix::Handler;
use actix::{ActorContext, Handler};
use ratatui::{
crossterm::event::{self, KeyCode, KeyEventKind},
style::Stylize as _,
widgets::Paragraph,
};
use crate::tui::actor::{messages::Tick, Tui}; use crate::tui::actor::{messages::Tick, Tui};
@ -14,44 +7,8 @@ impl Handler<Tick> for Tui {
type Result = std::io::Result<()>; type Result = std::io::Result<()>;
fn handle(&mut self, _msg: Tick, ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, _msg: Tick, ctx: &mut Self::Context) -> Self::Result {
if let Some(terminal) = self.terminal.borrow_mut() { self.draw()?;
terminal.draw(|frame| { self.handle_input(ctx)?;
let area = frame.area();
frame.render_widget(
Paragraph::new(format!(
"(press 'q' to quit) Ping:[{:?}] UI:[{:?}]",
self.last_ping,
Instant::now()
))
.white()
.on_blue(),
area,
);
})?;
} else {
eprintln!("No terminal setup");
}
if event::poll(std::time::Duration::from_millis(16))? {
if let event::Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') => {
// execute!(stderr(), LeaveAlternateScreen)?;
// disable_raw_mode()?;
ctx.stop();
if let Err(err) = self.signal_shutdown.send(()) {
tracing::error!(?err, "Failed to signal shutdown");
}
}
KeyCode::Esc => {
//
}
_ => (),
}
}
}
}
Ok(()) Ok(())
} }
} }

View file

@ -1,4 +1,4 @@
// //
use git_next_core::message; use git_next_core::message;
message!(Tick => std::io::Result<()>, "Start the TUI"); message!(Tick => std::io::Result<()>, "Update the TUI");

View file

@ -1,6 +1,7 @@
// //
mod handlers; mod handlers;
pub mod messages; pub mod messages;
mod model;
use std::{ use std::{
io::{stderr, Stderr}, io::{stderr, Stderr},
@ -8,10 +9,13 @@ use std::{
time::Instant, time::Instant,
}; };
use actix::{Actor, Context}; use actix::{Actor, ActorContext as _, Context};
pub use model::*;
use ratatui::{ use ratatui::{
crossterm::{ crossterm::{
event::{self, KeyCode, KeyEventKind},
execute, execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
}, },
@ -23,7 +27,8 @@ use ratatui::{
pub struct Tui { pub struct Tui {
terminal: Option<Terminal<CrosstermBackend<Stderr>>>, terminal: Option<Terminal<CrosstermBackend<Stderr>>>,
signal_shutdown: Sender<()>, signal_shutdown: Sender<()>,
last_ping: Instant, last_update: Instant,
state: State,
} }
impl Actor for Tui { impl Actor for Tui {
type Context = Context<Self>; type Context = Context<Self>;
@ -58,9 +63,49 @@ impl Tui {
Self { Self {
terminal: None, terminal: None,
signal_shutdown, signal_shutdown,
last_ping: Instant::now(), last_update: Instant::now(),
state: State::default(),
} }
} }
pub const fn state(&self) -> &State {
&self.state
}
fn draw(&mut self) -> std::io::Result<()> {
let t = self.terminal.take();
if let Some(mut terminal) = t {
terminal.draw(|frame| {
frame.render_widget(self.state(), frame.area());
})?;
self.terminal = Some(terminal);
} else {
eprintln!("No terminal setup");
}
Ok(())
}
fn handle_input(&self, ctx: &mut <Self as actix::Actor>::Context) -> std::io::Result<()> {
if event::poll(std::time::Duration::from_millis(16))? {
if let event::Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Char('q') => {
ctx.stop();
if let Err(err) = self.signal_shutdown.send(()) {
tracing::error!(?err, "Failed to signal shutdown");
}
}
KeyCode::Esc => {
//
}
_ => (),
}
}
}
}
Ok(())
}
} }
fn init() -> std::io::Result<Terminal<CrosstermBackend<Stderr>>> { fn init() -> std::io::Result<Terminal<CrosstermBackend<Stderr>>> {

View file

@ -0,0 +1,138 @@
//
use ratatui::{
prelude::{Buffer, Rect},
style::Stylize as _,
symbols::border,
text::Line,
widgets::{block::Title, Block, Paragraph, Widget},
};
use git_next_core::{git::graph::Log, ForgeAlias, RepoAlias, RepoBranches};
use std::collections::BTreeMap;
use derive_more::derive::Constructor;
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct State {
mode: StateMode,
repos: ReposState,
}
impl State {
pub fn loaded(repos: ReposState) -> Self {
Self {
mode: StateMode::Loaded,
repos,
}
}
pub const fn repos(&self) -> &ReposState {
&self.repos
}
pub fn repos_mut(&mut self) -> &mut ReposState {
&mut self.repos
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum StateMode {
#[default]
Initial,
Loaded,
}
pub type ReposState = BTreeMap<ForgeRepoKey, RepoState>;
#[derive(Clone, Debug, Constructor)]
pub struct RepoEntry {
pub forge_alias: ForgeAlias,
pub repo_alias: RepoAlias,
pub repo_state: RepoState,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Constructor)]
pub struct ForgeRepoKey {
forge_alias: ForgeAlias,
repo_alias: RepoAlias,
}
impl ForgeRepoKey {
pub const fn forge_alias(&self) -> &ForgeAlias {
&self.forge_alias
}
pub const fn repo_alias(&self) -> &RepoAlias {
&self.repo_alias
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RepoState {
Loading,
Loaded { branches: RepoBranches, log: Log },
}
impl From<&State> for Vec<RepoEntry> {
fn from(state: &State) -> Self {
state
.repos
.iter()
.map(|(k, v)| RepoEntry::new(k.forge_alias.clone(), k.repo_alias.clone(), v.clone()))
.collect()
}
}
impl From<(&ForgeRepoKey, &RepoState)> for RepoEntry {
fn from(
(
ForgeRepoKey {
forge_alias,
repo_alias,
},
repo_state,
): (&ForgeRepoKey, &RepoState),
) -> Self {
Self::new(forge_alias.clone(), repo_alias.clone(), repo_state.clone())
}
}
impl Widget for &State {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
match self.mode {
StateMode::Initial => Paragraph::new("Loading..."),
StateMode::Loaded => Paragraph::new(vec![
Line::from(format!("Monitored repos: {}", self.repos().len())), // self.repos()
// .iter()
// .map(RepoEntry::from)
// .enumerate()
// .map(|(i, repo)| {
// let state = match repo.repo_state {
// crate::tui::actor::RepoState::Loading => "Loading...".to_string(),
// crate::tui::actor::RepoState::Loaded { branches, log } => {
// format!("{branches}: {log:?}")
// }
// };
// Line::from(format!(
// "{i}: {}/{}: {state}",
// repo.forge_alias, repo.repo_alias
// ))
// })
// .collect::<Vec<Line>>(),
]),
}
.block(
Block::bordered()
.title(
Title::from(" Git-Next ".bold()).alignment(ratatui::layout::Alignment::Center),
)
.title(
Title::from(Line::from(vec![" [q]uit ".into()]))
.alignment(ratatui::layout::Alignment::Center)
.position(ratatui::widgets::block::Position::Bottom),
)
.border_set(border::THICK),
)
.render(area, buf);
}
}

View file

@ -1,5 +1,6 @@
// //
mod actor; mod actor;
mod widgets;
pub use actor::messages::Tick; pub use actor::messages::Tick;
pub use actor::Tui; pub use actor::Tui;

View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@
pub mod app;

View file

@ -1,5 +1,4 @@
// //
use std::borrow::ToOwned; use std::borrow::ToOwned;
use take_until::TakeUntilExt; use take_until::TakeUntilExt;