WIP: feat(tui): update state model from server messages
This commit is contained in:
parent
622e144986
commit
1102a1fab6
18 changed files with 247 additions and 90 deletions
|
@ -12,7 +12,8 @@ keywords = { workspace = true }
|
|||
categories = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["forgejo", "github"]
|
||||
# default = ["forgejo", "github"]
|
||||
default = ["forgejo", "github", "tui"]
|
||||
forgejo = ["git-next-forge-forgejo"]
|
||||
github = ["git-next-forge-github"]
|
||||
tui = ["ratatui"]
|
||||
|
|
|
@ -2,10 +2,13 @@
|
|||
use actix::prelude::*;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::repo::{
|
||||
do_send,
|
||||
messages::{ReceiveRepoConfig, RegisterWebhook},
|
||||
RepoActor,
|
||||
use crate::{
|
||||
repo::{
|
||||
do_send,
|
||||
messages::{ReceiveRepoConfig, RegisterWebhook},
|
||||
RepoActor,
|
||||
},
|
||||
server::actor::messages::ServerUpdate,
|
||||
};
|
||||
|
||||
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))]
|
||||
fn handle(&mut self, msg: ReceiveRepoConfig, ctx: &mut Self::Context) -> Self::Result {
|
||||
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());
|
||||
}
|
||||
|
|
|
@ -53,6 +53,9 @@ impl Handler<ValidateRepo> for RepoActor {
|
|||
};
|
||||
logger(self.log.as_ref(), "have repo config");
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
self.update_tui();
|
||||
|
||||
match validate(&**open_repository, &self.repo_details, &repo_config) {
|
||||
Ok(Positions {
|
||||
main,
|
||||
|
|
|
@ -135,7 +135,7 @@ fn handle_push(
|
|||
last_commit: &mut Option<Commit>,
|
||||
log: Option<&ActorLog>,
|
||||
) -> Result<(), ()> {
|
||||
logger(log, "message is for dev branch");
|
||||
logger(log, format!("message is for {branch} branch"));
|
||||
let commit = Commit::from(push);
|
||||
if last_commit.as_ref() == Some(&commit) {
|
||||
logger(log, format!("not a new commit on {branch}"));
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
//
|
||||
use actix::prelude::*;
|
||||
|
||||
use crate::alerts::messages::NotifyUser;
|
||||
use crate::{alerts::messages::NotifyUser, server::ServerActor};
|
||||
use derive_more::Deref;
|
||||
use kxio::network::Network;
|
||||
use std::time::Duration;
|
||||
|
@ -59,6 +59,7 @@ pub struct RepoActor {
|
|||
forge: Box<dyn git::ForgeLike>,
|
||||
log: Option<ActorLog>,
|
||||
notify_user_recipient: Option<Recipient<NotifyUser>>,
|
||||
server_addr: Option<Addr<ServerActor>>,
|
||||
}
|
||||
impl RepoActor {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
@ -71,6 +72,7 @@ impl RepoActor {
|
|||
repository_factory: Box<dyn RepositoryFactory>,
|
||||
sleep_duration: std::time::Duration,
|
||||
notify_user_recipient: Option<Recipient<NotifyUser>>,
|
||||
server_addr: Option<Addr<ServerActor>>,
|
||||
) -> Self {
|
||||
let message_token = messages::MessageToken::default();
|
||||
Self {
|
||||
|
@ -90,6 +92,18 @@ impl RepoActor {
|
|||
sleep_duration,
|
||||
log: None,
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -195,6 +195,7 @@ pub fn a_repo_actor(
|
|||
repository_factory,
|
||||
std::time::Duration::from_nanos(1),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.with_log(actors_log),
|
||||
log,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
mod file_updated;
|
||||
mod receive_app_config;
|
||||
mod receive_valid_app_config;
|
||||
mod server_update;
|
||||
mod shutdown;
|
||||
mod subscribe_updates;
|
||||
|
|
|
@ -8,7 +8,7 @@ use crate::{
|
|||
alerts::messages::UpdateShout,
|
||||
repo::{messages::CloneRepo, RepoActor},
|
||||
server::actor::{
|
||||
messages::{ReceiveValidAppConfig, ValidAppConfig},
|
||||
messages::{ReceiveValidAppConfig, ServerUpdate, ValidAppConfig},
|
||||
ServerActor,
|
||||
},
|
||||
webhook::{
|
||||
|
@ -21,7 +21,7 @@ use crate::{
|
|||
impl Handler<ReceiveValidAppConfig> for ServerActor {
|
||||
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 {
|
||||
app_config,
|
||||
socket_address,
|
||||
|
@ -37,6 +37,7 @@ impl Handler<ReceiveValidAppConfig> for ServerActor {
|
|||
let webhook_router = WebhookRouterActor::default().start();
|
||||
let listen_url = app_config.listen().url();
|
||||
let notify_user_recipient = self.alerts.clone().recipient();
|
||||
let server_addr = Some(ctx.address());
|
||||
// Forge Actors
|
||||
for (forge_alias, forge_config) in app_config.forges() {
|
||||
let repo_actors = self
|
||||
|
@ -46,6 +47,7 @@ impl Handler<ReceiveValidAppConfig> for ServerActor {
|
|||
&server_storage,
|
||||
listen_url,
|
||||
¬ify_user_recipient,
|
||||
server_addr.clone(),
|
||||
)
|
||||
.into_iter()
|
||||
.map(start_repo_actor)
|
||||
|
@ -69,9 +71,9 @@ impl Handler<ReceiveValidAppConfig> for ServerActor {
|
|||
WebhookActor::new(socket_address, webhook_router.recipient()).start();
|
||||
self.webhook_actor_addr.replace(webhook_actor_addr);
|
||||
let shout = app_config.shout().clone();
|
||||
ctx.notify(ServerUpdate::from(&app_config));
|
||||
self.app_config.replace(app_config);
|
||||
self.alerts.do_send(UpdateShout::new(shout));
|
||||
self.send_server_updates();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
14
crates/cli/src/server/actor/handlers/server_update.rs
Normal file
14
crates/cli/src/server/actor/handlers/server_update.rs
Normal 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());
|
||||
});
|
||||
}
|
||||
}
|
|
@ -3,10 +3,10 @@ use actix::{Message, Recipient};
|
|||
use derive_more::Constructor;
|
||||
|
||||
use git_next_core::{
|
||||
git::graph::Log,
|
||||
git::{self, graph::Log, RepoDetails},
|
||||
message,
|
||||
server::{AppConfig, Storage},
|
||||
ForgeAlias, RepoAlias, RepoBranches,
|
||||
ForgeAlias, RepoAlias, RepoBranches, RepoConfig,
|
||||
};
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
@ -40,20 +40,61 @@ message!(Shutdown, "Notification to shutdown the server actor");
|
|||
#[derive(Clone, Debug, PartialEq, Eq, Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub enum ServerUpdate {
|
||||
/// Status of a repo
|
||||
UpdateRepoSummary {
|
||||
/// List of all configured forges and aliases
|
||||
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,
|
||||
repo_alias: RepoAlias,
|
||||
branches: RepoBranches,
|
||||
log: Log,
|
||||
},
|
||||
/// remove a repo
|
||||
RemoveRepo {
|
||||
forge_alias: ForgeAlias,
|
||||
repo_alias: RepoAlias,
|
||||
},
|
||||
/// test message
|
||||
Ping,
|
||||
}
|
||||
|
||||
impl From<&AppConfig> for ServerUpdate {
|
||||
fn from(app_config: &AppConfig) -> Self {
|
||||
Self::ForgeRepoList {
|
||||
list: app_config
|
||||
.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!(
|
||||
|
|
|
@ -118,6 +118,7 @@ impl ServerActor {
|
|||
server_storage: &Storage,
|
||||
listen_url: &ListenUrl,
|
||||
notify_user_recipient: &Recipient<NotifyUser>,
|
||||
server_addr: Option<Addr<Self>>,
|
||||
) -> Vec<(ForgeAlias, RepoAlias, RepoActor)> {
|
||||
let span =
|
||||
tracing::info_span!("create_forge_repos", name = %forge_name, config = %forge_config);
|
||||
|
@ -125,8 +126,13 @@ impl ServerActor {
|
|||
let _guard = span.enter();
|
||||
tracing::info!("Creating Forge");
|
||||
let mut repos = vec![];
|
||||
let creator =
|
||||
self.create_actor(forge_name, forge_config.clone(), server_storage, listen_url);
|
||||
let creator = self.create_actor(
|
||||
forge_name,
|
||||
forge_config.clone(),
|
||||
server_storage,
|
||||
listen_url,
|
||||
server_addr,
|
||||
);
|
||||
for (repo_alias, server_repo_config) in forge_config.repos() {
|
||||
let forge_repo = creator((
|
||||
repo_alias,
|
||||
|
@ -148,6 +154,7 @@ impl ServerActor {
|
|||
forge_config: ForgeConfig,
|
||||
server_storage: &Storage,
|
||||
listen_url: &ListenUrl,
|
||||
server_addr: Option<Addr<Self>>,
|
||||
) -> impl Fn(
|
||||
(RepoAlias, &ServerRepoConfig, Recipient<NotifyUser>),
|
||||
) -> (ForgeAlias, RepoAlias, RepoActor) {
|
||||
|
@ -194,6 +201,7 @@ impl ServerActor {
|
|||
repository_factory.duplicate(),
|
||||
sleep_duration,
|
||||
Some(notify_user_recipient),
|
||||
server_addr.clone(),
|
||||
);
|
||||
(forge_name.clone(), repo_alias, actor)
|
||||
}
|
||||
|
@ -242,10 +250,4 @@ impl ServerActor {
|
|||
ctx.address().do_send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_server_updates(&self) {
|
||||
self.subscribers.iter().for_each(|subscriber| {
|
||||
subscriber.do_send(ServerUpdate::Ping);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,27 +1,54 @@
|
|||
use std::time::Instant;
|
||||
|
||||
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 {
|
||||
type Result = ();
|
||||
|
||||
fn handle(&mut self, msg: ServerUpdate, _ctx: &mut Self::Context) -> Self::Result {
|
||||
self.last_update = Instant::now();
|
||||
match msg {
|
||||
ServerUpdate::UpdateRepoSummary {
|
||||
ServerUpdate::ForgeRepoList { list } => {
|
||||
self.state = State::new(
|
||||
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().insert(
|
||||
ForgeRepoKey::new(forge_alias, repo_alias),
|
||||
RepoState::Loaded {
|
||||
branches,
|
||||
log: Log::default(),
|
||||
},
|
||||
);
|
||||
}
|
||||
ServerUpdate::UpdateRepoDetails {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
branches,
|
||||
log,
|
||||
} => todo!(),
|
||||
ServerUpdate::RemoveRepo {
|
||||
forge_alias,
|
||||
repo_alias,
|
||||
} => todo!(),
|
||||
ServerUpdate::Ping => {
|
||||
self.last_ping = Instant::now();
|
||||
} => {
|
||||
self.state.repos().insert(
|
||||
ForgeRepoKey::new(forge_alias, repo_alias),
|
||||
RepoState::Loaded { branches, log },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,5 @@
|
|||
//
|
||||
use std::{borrow::BorrowMut, time::Instant};
|
||||
|
||||
use actix::{ActorContext, Handler};
|
||||
use ratatui::{
|
||||
crossterm::event::{self, KeyCode, KeyEventKind},
|
||||
style::Stylize as _,
|
||||
widgets::Paragraph,
|
||||
};
|
||||
use actix::Handler;
|
||||
|
||||
use crate::tui::actor::{messages::Tick, Tui};
|
||||
|
||||
|
@ -14,44 +7,8 @@ impl Handler<Tick> for Tui {
|
|||
type Result = std::io::Result<()>;
|
||||
|
||||
fn handle(&mut self, _msg: Tick, ctx: &mut Self::Context) -> Self::Result {
|
||||
if let Some(terminal) = self.terminal.borrow_mut() {
|
||||
terminal.draw(|frame| {
|
||||
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 => {
|
||||
//
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.draw()?;
|
||||
self.handle_input(ctx)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
//
|
||||
use git_next_core::message;
|
||||
|
||||
message!(Tick => std::io::Result<()>, "Start the TUI");
|
||||
message!(Tick => std::io::Result<()>, "Update the TUI");
|
||||
|
|
|
@ -1,21 +1,27 @@
|
|||
//
|
||||
mod handlers;
|
||||
pub mod messages;
|
||||
mod model;
|
||||
|
||||
use std::{
|
||||
borrow::BorrowMut as _,
|
||||
io::{stderr, Stderr},
|
||||
sync::mpsc::Sender,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use actix::{Actor, Context};
|
||||
use actix::{Actor, ActorContext as _, Context};
|
||||
|
||||
use model::State;
|
||||
use ratatui::{
|
||||
crossterm::{
|
||||
event::{self, KeyCode, KeyEventKind},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
},
|
||||
prelude::CrosstermBackend,
|
||||
style::Stylize as _,
|
||||
widgets::Paragraph,
|
||||
Terminal,
|
||||
};
|
||||
|
||||
|
@ -23,7 +29,8 @@ use ratatui::{
|
|||
pub struct Tui {
|
||||
terminal: Option<Terminal<CrosstermBackend<Stderr>>>,
|
||||
signal_shutdown: Sender<()>,
|
||||
last_ping: Instant,
|
||||
last_update: Instant,
|
||||
state: State,
|
||||
}
|
||||
impl Actor for Tui {
|
||||
type Context = Context<Self>;
|
||||
|
@ -58,9 +65,55 @@ impl Tui {
|
|||
Self {
|
||||
terminal: None,
|
||||
signal_shutdown,
|
||||
last_ping: Instant::now(),
|
||||
last_update: Instant::now(),
|
||||
state: State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(&mut self) -> std::io::Result<()> {
|
||||
if let Some(terminal) = self.terminal.borrow_mut() {
|
||||
terminal.draw(|frame| {
|
||||
let area = frame.area();
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"{:?}",
|
||||
// "(press 'q' to quit) Update:[{:?}] UI:[{:?}] repos[{:?}]",
|
||||
// self.last_update,
|
||||
// Instant::now(),
|
||||
self.state
|
||||
))
|
||||
.white()
|
||||
.on_blue(),
|
||||
area,
|
||||
);
|
||||
})?;
|
||||
} 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>>> {
|
||||
|
|
30
crates/cli/src/tui/actor/model.rs
Normal file
30
crates/cli/src/tui/actor/model.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
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, Constructor)]
|
||||
pub struct State {
|
||||
repos: ReposState,
|
||||
}
|
||||
impl State {
|
||||
pub fn repos(&mut self) -> &mut ReposState {
|
||||
&mut self.repos
|
||||
}
|
||||
}
|
||||
|
||||
pub type ReposState = BTreeMap<ForgeRepoKey, RepoState>;
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Constructor)]
|
||||
pub struct ForgeRepoKey {
|
||||
forge_alias: ForgeAlias,
|
||||
repo_alias: RepoAlias,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RepoState {
|
||||
Loading,
|
||||
Loaded { branches: RepoBranches, log: Log },
|
||||
}
|
2
crates/cli/src/tui/render.rs
Normal file
2
crates/cli/src/tui/render.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
//
|
||||
|
||||
use std::borrow::ToOwned;
|
||||
|
||||
use take_until::TakeUntilExt;
|
||||
|
|
Loading…
Reference in a new issue