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 7a4f9a45a6
commit 77eee77dcb
55 changed files with 1634 additions and 188 deletions

58
Cargo.lock generated
View file

@ -704,6 +704,15 @@ dependencies = [
"subtle",
]
[[package]]
name = "directories"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs"
version = "1.0.5"
@ -711,10 +720,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901"
dependencies = [
"libc",
"redox_users",
"redox_users 0.3.5",
"winapi",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.5",
"windows-sys 0.48.0",
]
[[package]]
name = "doc-comment"
version = "0.3.3"
@ -1046,13 +1067,16 @@ dependencies = [
"assert2",
"bytes",
"clap",
"color-eyre",
"derive-with",
"derive_more",
"directories",
"git-conventional",
"git-next-core",
"git-next-forge-forgejo",
"git-next-forge-github",
"kxio",
"lazy_static",
"lettre",
"mockall",
"notifica",
@ -1070,6 +1094,7 @@ dependencies = [
"time",
"toml",
"tracing",
"tracing-error",
"tracing-subscriber",
"ulid",
"warp",
@ -2537,9 +2562,9 @@ dependencies = [
[[package]]
name = "lazy_static"
version = "1.4.0"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lettre"
@ -2581,6 +2606,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "libredox"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags 2.5.0",
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.13"
@ -2968,6 +3003,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "overload"
version = "0.1.1"
@ -3277,6 +3318,17 @@ dependencies = [
"rust-argon2",
]
[[package]]
name = "redox_users"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891"
dependencies = [
"getrandom 0.2.14",
"libredox",
"thiserror",
]
[[package]]
name = "regex"
version = "1.10.4"

View file

@ -28,13 +28,17 @@ git-next-forge-github = { path = "crates/forge-github", version = "0.13" }
# TUI
ratatui = "0.28"
directories = "5.0.1"
lazy_static = "1.5.0"
color-eyre = "0.6.3"
# CLI parsing
clap = { version = "4.5", features = ["cargo", "derive"] }
# logging
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-error = "0.2.0"
# base64 decoding
base64 = "0.22"

View file

@ -12,10 +12,11 @@ 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"]
tui = ["ratatui", "directories", "lazy_static"]
[dependencies]
git-next-core = { workspace = true }
@ -24,6 +25,9 @@ git-next-forge-github = { workspace = true, optional = true }
# TUI
ratatui = { workspace = true, optional = true }
directories = { workspace = true, optional = true }
lazy_static = { workspace = true, optional = true }
color-eyre = { workspace = true }
# CLI parsing
clap = { workspace = true }
@ -34,6 +38,7 @@ kxio = { workspace = true }
# logging
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-error.workspace = true
# Conventional Commit check
git-conventional = { workspace = true }

View file

@ -1,5 +1,5 @@
//
use anyhow::{Context, Result};
use color_eyre::{eyre::Context, Result};
use kxio::fs::FileSystem;
pub fn run(fs: &FileSystem) -> Result<()> {

View file

@ -1,4 +1,6 @@
//
#![allow(clippy::module_name_repetitions)]
mod alerts;
mod file_watcher;
mod forge;
@ -17,8 +19,8 @@ use git_next_core::git;
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
use color_eyre::Result;
use kxio::{fs, network::Network};
#[derive(Parser, Debug)]

View file

@ -17,15 +17,13 @@ use tracing::{info, instrument, warn};
// advance next to the next commit towards the head of the dev branch
#[instrument(fields(next), skip_all)]
pub fn advance_next(
next: &Commit,
main: &Commit,
dev_commit_history: &[Commit],
commit: Option<Commit>,
force: git_next_core::git::push::Force,
repo_details: RepoDetails,
repo_config: RepoConfig,
open_repository: &dyn OpenRepositoryLike,
message_token: MessageToken,
) -> Result<MessageToken> {
let (commit, force) = find_next_commit_on_dev(next, main, dev_commit_history);
let commit = commit.ok_or_else(|| Error::NextAtDev)?;
validate_commit_message(commit.message())?;
info!("Advancing next to commit '{}'", commit);

View file

@ -5,11 +5,14 @@ use git_next_core::RepoConfigSource;
use tracing::warn;
use crate::repo::{
use crate::{
repo::{
branch::advance_main,
do_send,
messages::{AdvanceMain, LoadConfigFromRepo, ValidateRepo},
RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<AdvanceMain> for RepoActor {
@ -26,13 +29,13 @@ impl Handler<AdvanceMain> for RepoActor {
let repo_details = self.repo_details.clone();
let addr = ctx.address();
let message_token = self.message_token;
let commit = msg.unwrap();
match advance_main(
msg.unwrap(),
&repo_details,
&repo_config,
&**open_repository,
) {
self.update_tui(RepoUpdate::AdvancingMain {
commit: commit.clone(),
});
match advance_main(commit, &repo_details, &repo_config, &**open_repository) {
Err(err) => {
warn!("advance main: {err}");
}

View file

@ -3,11 +3,14 @@ use actix::prelude::*;
use tracing::warn;
use crate::repo::{
branch::advance_next,
use crate::{
repo::{
branch::{advance_next, find_next_commit_on_dev},
do_send,
messages::{AdvanceNext, AdvanceNextPayload, ValidateRepo},
RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<AdvanceNext> for RepoActor {
@ -20,6 +23,7 @@ impl Handler<AdvanceNext> for RepoActor {
let Some(open_repository) = &self.open_repository else {
return;
};
let AdvanceNextPayload {
next,
main,
@ -29,10 +33,15 @@ impl Handler<AdvanceNext> for RepoActor {
let repo_config = repo_config.clone();
let addr = ctx.address();
let (commit, force) = find_next_commit_on_dev(&next, &main, &dev_commit_history);
self.update_tui(RepoUpdate::AdvancingNext {
commit: commit.clone(),
force: force.clone(),
});
match advance_next(
&next,
&main,
&dev_commit_history,
commit,
force,
repo_details,
repo_config,
&**open_repository,

View file

@ -3,10 +3,13 @@ use actix::prelude::*;
use tracing::{debug, Instrument as _};
use crate::repo::{
use crate::{
repo::{
do_send,
messages::{CheckCIStatus, ReceiveCIStatus},
RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<CheckCIStatus> for RepoActor {
@ -14,11 +17,13 @@ impl Handler<CheckCIStatus> for RepoActor {
fn handle(&mut self, msg: CheckCIStatus, ctx: &mut Self::Context) -> Self::Result {
crate::repo::logger(self.log.as_ref(), "start: CheckCIStatus");
let addr = ctx.address();
let forge = self.forge.duplicate();
let next = msg.unwrap();
let log = self.log.clone();
self.update_tui(RepoUpdate::CheckingCI);
// get the status - pass, fail, pending (all others map to fail, e.g. error)
async move {
let status = forge.commit_status(&next).await;

View file

@ -4,10 +4,13 @@ use actix::prelude::*;
use git_next_core::git;
use tracing::{debug, instrument, warn};
use crate::repo::{
use crate::{
repo::{
do_send, logger,
messages::{CloneRepo, LoadConfigFromRepo, RegisterWebhook},
RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<CloneRepo> for RepoActor {
@ -15,11 +18,13 @@ impl Handler<CloneRepo> for RepoActor {
#[instrument(name = "RepoActor::CloneRepo", skip_all, fields(repo = %self.repo_details))]
fn handle(&mut self, _msg: CloneRepo, ctx: &mut Self::Context) -> Self::Result {
logger(self.log.as_ref(), "Handler: CloneRepo: start");
self.update_tui(RepoUpdate::Opening);
debug!("Handler: CloneRepo: start");
match git::repository::open(&*self.repository_factory, &self.repo_details) {
Ok(repository) => {
logger(self.log.as_ref(), "open okay");
debug!("open okay");
self.update_tui(RepoUpdate::Opened);
self.open_repository.replace(repository);
if self.repo_details.repo_config.is_none() {
do_send(&ctx.address(), LoadConfigFromRepo, self.log.as_ref());
@ -30,6 +35,7 @@ impl Handler<CloneRepo> for RepoActor {
Err(err) => {
logger(self.log.as_ref(), "open failed");
warn!("Could not open repo: {err:?}");
self.alert_tui(err.to_string());
}
}
debug!("Handler: CloneRepo: finish");

View file

@ -5,10 +5,13 @@ use git_next_core::git::UserNotification;
use tracing::{debug, instrument, Instrument as _};
use crate::repo::{
use crate::{
repo::{
do_send, load,
messages::{LoadConfigFromRepo, ReceiveRepoConfig},
notify_user, RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<LoadConfigFromRepo> for RepoActor {
@ -16,6 +19,7 @@ impl Handler<LoadConfigFromRepo> for RepoActor {
#[instrument(name = "Repocrate::repo::LoadConfigFromRepo", skip_all, fields(repo = %self.repo_details))]
fn handle(&mut self, _msg: LoadConfigFromRepo, ctx: &mut Self::Context) -> Self::Result {
debug!("Handler: LoadConfigFromRepo: start");
self.update_tui(RepoUpdate::LoadingConfigFromRepo);
let Some(open_repository) = &self.open_repository else {
return;
};

View file

@ -4,10 +4,13 @@ use actix::prelude::*;
use git_next_core::git::{forge::commit::Status, graph, UserNotification};
use tracing::debug;
use crate::repo::{
use crate::{
repo::{
delay_send, do_send, logger,
messages::{AdvanceMain, ReceiveCIStatus, ValidateRepo},
notify_user, RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<ReceiveCIStatus> for RepoActor {
@ -22,6 +25,11 @@ impl Handler<ReceiveCIStatus> for RepoActor {
let repo_alias = self.repo_details.repo_alias.clone();
let message_token = self.message_token;
let sleep_duration = self.sleep_duration;
let graph_log = graph::log(&self.repo_details);
self.update_tui_log(graph_log.clone());
self.update_tui(RepoUpdate::ReceiveCIStatus {
status: status.clone(),
});
debug!(?status, "");
match status {
@ -34,13 +42,14 @@ impl Handler<ReceiveCIStatus> for RepoActor {
}
Status::Fail => {
tracing::warn!("Checks have failed");
notify_user(
self.notify_user_recipient.as_ref(),
UserNotification::CICheckFailed {
forge_alias,
repo_alias,
commit: next,
log: graph::log(&self.repo_details),
log: graph_log,
},
log.as_ref(),
);

View file

@ -2,10 +2,13 @@
use actix::prelude::*;
use tracing::instrument;
use crate::repo::{
use crate::{
repo::{
do_send,
messages::{ReceiveRepoConfig, RegisterWebhook},
RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<ReceiveRepoConfig> for RepoActor {
@ -13,8 +16,11 @@ 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.update_tui(RepoUpdate::ReceiveRepoConfig {
repo_config: repo_config.clone(),
});
self.repo_details.repo_config.replace(repo_config);
self.update_tui_branches();
do_send(&ctx.address(), RegisterWebhook::new(), self.log.as_ref());
}
}

View file

@ -1,12 +1,15 @@
//
use actix::prelude::*;
use tracing::{debug, Instrument as _};
use tracing::{debug, error, Instrument as _};
use crate::repo::{
use crate::{
repo::{
do_send,
messages::{RegisterWebhook, WebhookRegistered},
notify_user, RepoActor,
},
server::actor::messages::RepoUpdate,
};
use git_next_core::git::UserNotification;
@ -25,11 +28,12 @@ impl Handler<RegisterWebhook> for RepoActor {
let addr = ctx.address();
let notify_user_recipient = self.notify_user_recipient.clone();
let log = self.log.clone();
self.update_tui(RepoUpdate::RegisteringWebhook);
debug!("registering webhook");
async move {
match forge.register_webhook(&repo_listen_url).await {
Ok(registered_webhook) => {
debug!(?registered_webhook, "");
debug!(?registered_webhook, "webhook registered");
do_send(
&addr,
WebhookRegistered::from(registered_webhook),
@ -37,6 +41,7 @@ impl Handler<RegisterWebhook> for RepoActor {
);
}
Err(err) => {
error!(?err, "failed to register webhook");
notify_user(
notify_user_recipient.as_ref(),
UserNotification::WebhookRegistration {
@ -52,6 +57,8 @@ impl Handler<RegisterWebhook> for RepoActor {
.in_current_span()
.into_actor(self)
.wait(ctx);
} else {
self.alert_tui("already have a webhook id - cant register webhook");
}
}
}

View file

@ -3,13 +3,17 @@ use actix::prelude::*;
use tracing::{debug, warn, Instrument as _};
use crate::repo::{messages::UnRegisterWebhook, RepoActor};
use crate::{
repo::{messages::UnRegisterWebhook, RepoActor},
server::actor::messages::RepoUpdate,
};
impl Handler<UnRegisterWebhook> for RepoActor {
type Result = ();
fn handle(&mut self, _msg: UnRegisterWebhook, ctx: &mut Self::Context) -> Self::Result {
if let Some(webhook_id) = self.webhook_id.take() {
self.update_tui(RepoUpdate::UnregisteringWebhook);
let forge = self.forge.duplicate();
debug!("unregistering webhook");
async move {

View file

@ -3,10 +3,13 @@ use actix::prelude::*;
use tracing::{debug, instrument, Instrument as _};
use crate::repo::{
use crate::{
repo::{
do_send, logger,
messages::{AdvanceNext, AdvanceNextPayload, CheckCIStatus, MessageToken, ValidateRepo},
notify_user, RepoActor,
},
server::actor::messages::RepoUpdate,
};
use git_next_core::git::validation::positions::{validate, Error, Positions};
@ -41,14 +44,18 @@ impl Handler<ValidateRepo> for RepoActor {
format!("accepted token: {}", self.message_token),
);
self.update_tui(RepoUpdate::ValidateRepo);
// Repository positions
let Some(ref open_repository) = self.open_repository else {
logger(self.log.as_ref(), "no open repository");
self.alert_tui("repo not open");
return;
};
logger(self.log.as_ref(), "have open repository");
let Some(repo_config) = self.repo_details.repo_config.clone() else {
logger(self.log.as_ref(), "no repo config");
self.alert_tui("no repo config");
return;
};
logger(self.log.as_ref(), "have repo config");
@ -75,10 +82,11 @@ impl Handler<ValidateRepo> for RepoActor {
self.log.as_ref(),
);
} else {
// do nothing
self.update_tui(RepoUpdate::Okay { main, next, dev });
}
}
Err(Error::Retryable(message)) => {
self.alert_tui(format!("[retryable: {message}]"));
logger(self.log.as_ref(), message);
let addr = ctx.address();
let message_token = self.message_token;
@ -95,12 +103,16 @@ impl Handler<ValidateRepo> for RepoActor {
.into_actor(self)
.wait(ctx);
}
Err(Error::UserIntervention(user_notification)) => notify_user(
Err(Error::UserIntervention(user_notification)) => {
self.alert_tui(format!("[USER INTERVENTION: {user_notification}]"));
notify_user(
self.notify_user_recipient.as_ref(),
user_notification,
self.log.as_ref(),
),
);
}
Err(Error::NonRetryable(message)) => {
self.alert_tui(format!("[Error: {message}]"));
logger(self.log.as_ref(), message);
}
}

View file

@ -3,10 +3,13 @@ use actix::prelude::*;
use tracing::{info, instrument, warn};
use crate::repo::{
use crate::{
repo::{
do_send, logger,
messages::{ValidateRepo, WebhookNotification},
ActorLog, RepoActor,
},
server::actor::messages::RepoUpdate,
};
use git_next_core::{
@ -52,6 +55,10 @@ impl Handler<WebhookNotification> for RepoActor {
return;
}
Some(Branch::Main) => {
self.update_tui(RepoUpdate::WebhookReceived {
branch: Branch::Main,
push: push.clone(),
});
if handle_push(
push,
&config.branches().main(),
@ -64,6 +71,10 @@ impl Handler<WebhookNotification> for RepoActor {
};
}
Some(Branch::Next) => {
self.update_tui(RepoUpdate::WebhookReceived {
branch: Branch::Next,
push: push.clone(),
});
if handle_push(
push,
&config.branches().next(),
@ -76,6 +87,10 @@ impl Handler<WebhookNotification> for RepoActor {
};
}
Some(Branch::Dev) => {
self.update_tui(RepoUpdate::WebhookReceived {
branch: Branch::Dev,
push: push.clone(),
});
if handle_push(
push,
&config.branches().dev(),
@ -135,7 +150,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}"));

View file

@ -2,16 +2,20 @@
use actix::prelude::*;
use tracing::instrument;
use crate::repo::{
use crate::{
repo::{
do_send,
messages::{ValidateRepo, WebhookRegistered},
RepoActor,
},
server::actor::messages::RepoUpdate,
};
impl Handler<WebhookRegistered> for RepoActor {
type Result = ();
#[instrument(name = "RepoActor::WebhookRegistered", skip_all, fields(repo = %self.repo_details, webhook_id = %msg.webhook_id()))]
fn handle(&mut self, msg: WebhookRegistered, ctx: &mut Self::Context) -> Self::Result {
self.update_tui(RepoUpdate::RegisteredWebhook);
self.webhook_id.replace(msg.webhook_id().clone());
self.webhook_auth.replace(msg.webhook_auth().clone());
do_send(

View file

@ -1,7 +1,13 @@
//
use actix::prelude::*;
use crate::alerts::messages::NotifyUser;
use crate::{
alerts::messages::NotifyUser,
server::{
actor::messages::{RepoUpdate, ServerUpdate},
ServerActor,
},
};
use derive_more::Deref;
use kxio::network::Network;
use std::time::Duration;
@ -11,6 +17,7 @@ use git_next_core::{
git::{
self,
repository::{factory::RepositoryFactory, open::OpenRepositoryLike},
validation::positions::get_commit_histories,
UserNotification,
},
server::ListenUrl,
@ -59,6 +66,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 +79,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 +99,58 @@ impl RepoActor {
sleep_duration,
log: None,
notify_user_recipient,
server_addr,
}
}
fn update_tui_branches(&self) {
#[cfg(feature = "tui")]
{
use crate::server::actor::messages::RepoUpdate;
let Some(repo_config) = &self.repo_details.repo_config else {
return;
};
let Some(open_repo) = self.open_repository.as_ref() else {
return;
};
let branches = repo_config.branches().clone();
let histories = get_commit_histories(&**open_repo, repo_config).ok();
self.update_tui(RepoUpdate::Branches {
branches,
histories,
});
}
}
fn update_tui_log(&self, log: git::graph::Log) {
#[cfg(feature = "tui")]
{
self.update_tui(RepoUpdate::Log { log });
}
}
fn alert_tui(&self, alert: impl Into<String>) {
#[cfg(feature = "tui")]
{
self.update_tui(RepoUpdate::Alert {
alert: alert.into(),
});
}
}
fn update_tui(&self, repo_update: RepoUpdate) {
#[cfg(feature = "tui")]
{
let Some(server_addr) = &self.server_addr else {
return;
};
let update = ServerUpdate::RepoUpdate {
forge_alias: self.repo_details.forge.forge_alias().clone(),
repo_alias: self.repo_details.repo_alias.clone(),
repo_update,
};
server_addr.do_send(update);
}
}
}

View file

@ -1,6 +1,28 @@
//
use crate::repo::branch::find_next_commit_on_dev;
use super::*;
fn advance_next_sut(
next: &Commit,
main: &Commit,
dev_commit_history: &[Commit],
repo_details: RepoDetails,
repo_config: RepoConfig,
open_repository: &dyn OpenRepositoryLike,
message_token: MessageToken,
) -> branch::Result<MessageToken> {
let (commit, force) = find_next_commit_on_dev(next, main, dev_commit_history);
branch::advance_next(
commit,
force,
repo_details,
repo_config,
open_repository,
message_token,
)
}
mod when_at_dev {
// next and dev branches are the same
use super::*;
@ -16,7 +38,7 @@ mod when_at_dev {
// no on_push defined - so any call to push will cause an error
let message_token = given::a_message_token();
let_assert!(
Err(err) = branch::advance_next(
Err(err) = advance_next_sut(
&next,
main,
dev_commit_history,
@ -51,7 +73,7 @@ mod can_advance {
// no on_push defined - so any call to push will cause an error
let message_token = given::a_message_token();
let_assert!(
Err(err) = branch::advance_next(
Err(err) = advance_next_sut(
&next,
main,
dev_commit_history,
@ -82,7 +104,7 @@ mod can_advance {
// no on_push defined - so any call to push will cause an error
let message_token = given::a_message_token();
let_assert!(
Err(err) = branch::advance_next(
Err(err) = advance_next_sut(
&next,
main,
dev_commit_history,
@ -122,7 +144,7 @@ mod can_advance {
expect::push(&mut open_repository, Err(git::push::Error::Lock));
let message_token = given::a_message_token();
let_assert!(
Err(err) = branch::advance_next(
Err(err) = advance_next_sut(
&next,
main,
dev_commit_history,
@ -154,7 +176,7 @@ mod can_advance {
expect::push_ok(&mut open_repository);
let message_token = given::a_message_token();
let_assert!(
Ok(mt) = branch::advance_next(
Ok(mt) = advance_next_sut(
&next,
main,
dev_commit_history,

View file

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

View file

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

View file

@ -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,
&notify_user_recipient,
server_addr.clone(),
)
.into_iter()
.map(start_repo_actor)
@ -69,9 +71,18 @@ 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();
self.app_config.replace(app_config);
self.app_config.replace(app_config.clone());
self.do_send(
ServerUpdate::AppConfigLoaded {
app_config: ValidAppConfig {
app_config,
socket_address,
storage: server_storage,
},
},
ctx,
);
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

@ -1,12 +1,13 @@
//
use actix::{Message, Recipient};
//-
use derive_more::Constructor;
use git_next_core::{
git::graph::Log,
git::{self, forge::commit::Status, graph::Log, Commit},
message,
server::{AppConfig, Storage},
ForgeAlias, RepoAlias, RepoBranches,
webhook::{push::Branch, Push},
ForgeAlias, RepoAlias, RepoBranches, RepoConfig,
};
use std::net::SocketAddr;
@ -40,20 +41,58 @@ 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
AppConfigLoaded { app_config: ValidAppConfig },
RepoUpdate {
forge_alias: ForgeAlias,
repo_alias: RepoAlias,
repo_update: RepoUpdate,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RepoUpdate {
Branches {
branches: RepoBranches,
histories: Option<git::commit::Histories>,
},
Log {
log: Log,
},
/// remove a repo
RemoveRepo {
forge_alias: ForgeAlias,
repo_alias: RepoAlias,
ValidateRepo,
Okay {
main: Commit,
next: Commit,
dev: Commit,
},
/// test message
Ping,
Alert {
alert: String,
},
CheckingCI,
AdvancingNext {
commit: Option<git::Commit>,
force: git::push::Force,
},
AdvancingMain {
commit: git::Commit,
},
Opening,
LoadingConfigFromRepo,
ReceiveCIStatus {
status: Status,
},
ReceiveRepoConfig {
repo_config: RepoConfig,
},
RegisteringWebhook,
UnregisteringWebhook,
WebhookReceived {
branch: Branch,
push: Push,
},
RegisteredWebhook,
Opened,
}
message!(

View file

@ -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);
});
}
}

View file

@ -17,7 +17,7 @@ pub use actor::ServerActor;
use git_next_core::git::RepositoryFactory;
use anyhow::{Context, Result};
use color_eyre::{eyre::Context, Result};
use kxio::{fs::FileSystem, network::Network};
use tracing::info;
@ -48,7 +48,12 @@ pub fn start(
repo: Box<dyn RepositoryFactory>,
sleep_duration: std::time::Duration,
) -> Result<()> {
if !ui {
if ui {
#[cfg(feature = "tui")]
{
crate::tui::logging::initialize_logging()?;
}
} else {
init_logging();
}
@ -59,7 +64,6 @@ pub fn start(
info!("Starting Server...");
let server =
ServerActor::new(fs.clone(), net.clone(), alerts_addr, repo, sleep_duration).start();
server.do_send(FileUpdated);
info!("Starting File Watcher...");
#[allow(clippy::expect_used)]
@ -75,18 +79,18 @@ pub fn start(
let (tx_shutdown, rx_shutdown) = channel::<()>();
let tui_addr = tui::Tui::new(tx_shutdown).start();
// tui_addr.do_send(tui::Tick);
let _ = tui_addr.send(tui::Tick).await;
server.do_send(SubscribeToUpdates::new(tui_addr.clone().recipient()));
server.do_send(FileUpdated); // update file after ui subscription in place
loop {
let _ = tui_addr.send(tui::Tick).await;
if rx_shutdown.try_recv().is_ok() {
break;
}
// actix_rt::task::yield_now().await;
actix_rt::time::sleep(Duration::from_millis(16)).await;
}
}
} else {
server.do_send(FileUpdated);
info!("Server running - Press Ctrl-C to stop...");
let _ = signal::ctrl_c().await;
info!("Ctrl-C received, shutting down...");
@ -95,7 +99,7 @@ pub fn start(
// shutdown
fw_shutdown.store(true, Ordering::Relaxed);
server.do_send(crate::server::actor::messages::Shutdown);
actix_rt::time::sleep(std::time::Duration::from_millis(200)).await;
actix_rt::time::sleep(std::time::Duration::from_millis(10)).await;
System::current().stop();
};

View file

@ -0,0 +1,121 @@
# TUI Actor
- Maintains it's own copy of data for holding state
- Is notified of update via actor messages from the Server and Repo Actors
- State is somewhat heirarchical
## State
```rust
enum TuiState {
Initial,
Configured {
app_config: ValidAppConfig,
forges: BTreeMap<ForgeAlias, RepoState>,
},
}
enum RepoState {
Identified { repo_alias: RepoAlias },
Configured { repo_alias: RepoAlias, branches: RepoBranches },
Ready { repo_alias: RepoAlias, branches: RepoBranches, main: Commit, next: Commit, dev: Commit, log: Log },
Alert { repo_alias: RepoAlias, message: String, branches: RepoBranches, main: Commit, next: Commit, dev: Commit, log: Log },
}
```
## State Transitions:
### `TuiState`
```mermaid
stateDiagram-v2
* --> Initial
Initial --> Configured
```
- `message!(Configure, ValidAppConfig, "Initialise UI with valid config");`
### `RepoState`
```mermaid
stateDiagram-v2
* --> Identified
Identified --> Configured
Identified --> Ready
Configured --> Ready
Ready --> Alert
Configured --> Alert
Identified --> Alert
Alert --> Ready
```
- `Identified` - from AppConfig where a repo alias is listed, but repo config needs to be loaded from `.git-next.toml`
- `Configured` - as `Identified` but either branches are identified in server config, OR the `.git-next.toml` file has been loaded
- `Ready` - as `Configured` but the branch positions have been validated and do not require user intervention
- `Alert` - as `Ready` but user intervention is required
## Widget
Initial mock up of UI. Possibly add some borders and padding as it looks a little squached together.
```
+ gh
- jo
+ test (main/next/dev)
- tasyn (main/next/dev)
* 12ab32f (dev) added feature X
* bce43b1 (next) added feature Y
* f43e379 (main) added feature Z
- git-next (main/next/dev) DEV NOT AHEAD OF MAIN - rebase onto main
* 239cefd (main/next) fix bug A
* b4c290a (dev)
```
Adding a border around open forges:
```
+ gh
- jo --------------------------------------------------------------------+
| + test (main/next/dev) |
| - tasyn (main/next/dev) |
| * 12ab32f (dev) added feature X |
| * bce43b1 (next) added feature Y |
| * f43e379 (main) added feature Z |
| - git-next (main/next/dev) DEV NOT AHEAD OF MAIN - rebase onto main |
| * 239cefd (main/next) fix bug A |
| * b4c290a (dev) |
+------------------------------------------------------------------------+
```
Adding a border around open forges and repos (I like this one the best):
```
+ gh
- jo --------------------------------------------------------------------+
| + test (main/next/dev) |
| - tasyn (main/next/dev) ---------------------------------------------+ |
| | * 12ab32f (dev) added feature X | |
| | * bce43b1 (next) added feature Y | |
| | * f43e379 (main) added feature Z | |
| +--------------------------------------------------------------------+ |
| - git-next (main/next/dev) DEV NOT AHEAD OF MAIN - rebase onto main -+ |
| | * 239cefd (main/next) fix bug A | |
| | * b4c290a (dev) | |
| +--------------------------------------------------------------------+ |
+------------------------------------------------------------------------+
```
## Logging
- tui-logger to create an optional panel to show the normal server logs
## Branch Graph
- tui-nodes ?
## Scrolling
- tui-scrollview
## Tree View
- tui-tree-widget

View file

@ -1,27 +1,94 @@
use std::time::Instant;
//
use actix::Handler;
use crate::{server::actor::messages::ServerUpdate, tui::Tui};
use crate::{
server::actor::messages::{RepoUpdate, ServerUpdate},
tui::{
actor::{RepoState, ServerState},
Tui,
},
};
//
impl Handler<ServerUpdate> for Tui {
type Result = ();
fn handle(&mut self, msg: ServerUpdate, _ctx: &mut Self::Context) -> Self::Result {
self.state.tap();
match msg {
ServerUpdate::UpdateRepoSummary {
ServerUpdate::AppConfigLoaded { app_config } => {
self.state.mode = ServerState::from(app_config);
}
ServerUpdate::RepoUpdate {
forge_alias,
repo_alias,
repo_update,
} => {
if let ServerState::Configured { forges } = &mut self.state.mode {
let Some(forge_state) = forges.get_mut(&forge_alias) else {
return;
};
let Some(repo_state) = forge_state.repos.get_mut(&repo_alias) else {
return;
};
match repo_update {
RepoUpdate::Branches {
branches,
log,
} => todo!(),
ServerUpdate::RemoveRepo {
forge_alias,
repo_alias,
} => todo!(),
ServerUpdate::Ping => {
self.last_ping = Instant::now();
histories,
} => {
repo_state.update_branches(branches, histories);
}
RepoUpdate::Log { log } => {
repo_state.update_log(log);
}
RepoUpdate::ValidateRepo => repo_state.update_message("polling..."),
RepoUpdate::Okay { main, next, dev } => {
repo_state.clear_alert();
repo_state.update_message("okay");
*repo_state = RepoState::ready(repo_state.clone(), main, next, dev);
}
RepoUpdate::Alert { alert } => {
repo_state.alert(alert);
}
RepoUpdate::CheckingCI => {
repo_state.update_message("Checking CI status");
}
RepoUpdate::AdvancingNext {
commit: _,
force: _,
} => (),
RepoUpdate::AdvancingMain { commit } => {
repo_state.update_message(format!("advancing main to {commit}"));
}
RepoUpdate::Opening => {
repo_state.update_message("opening...");
}
RepoUpdate::Opened => {
repo_state.update_message("opened");
}
RepoUpdate::LoadingConfigFromRepo => {
repo_state.update_message("loading config from repo...");
}
RepoUpdate::ReceiveCIStatus { status } => {
repo_state.update_message(format!("ci status: {status:?}"));
}
RepoUpdate::ReceiveRepoConfig { repo_config: _ } => {
repo_state.update_message("loaded config from repo");
}
RepoUpdate::RegisteringWebhook => {
repo_state.update_message("registering webhook...");
}
RepoUpdate::UnregisteringWebhook => {
repo_state.update_message("unregistering webhook...");
}
RepoUpdate::WebhookReceived { branch, push: _ } => {
repo_state.update_message(format!("webhook update: {branch:?}"));
}
RepoUpdate::RegisteredWebhook => {
repo_state.update_message("registered webhook");
}
}
}
}
}
}

View file

@ -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,9 @@ 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.state.tap();
self.draw()?;
self.handle_input(ctx)?;
Ok(())
}
}

View file

@ -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");

View file

@ -1,17 +1,20 @@
//
mod handlers;
pub mod messages;
mod model;
use std::{
io::{stderr, Stderr},
sync::mpsc::Sender,
time::Instant,
};
use actix::{Actor, Context};
use actix::{Actor, ActorContext as _, Context};
pub use model::*;
use ratatui::{
crossterm::{
event::{self, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
},
@ -23,7 +26,7 @@ use ratatui::{
pub struct Tui {
terminal: Option<Terminal<CrosstermBackend<Stderr>>>,
signal_shutdown: Sender<()>,
last_ping: Instant,
pub state: State,
}
impl Actor for Tui {
type Context = Context<Self>;
@ -58,9 +61,48 @@ impl Tui {
Self {
terminal: None,
signal_shutdown,
last_ping: Instant::now(),
state: State::initial(),
}
}
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>>> {

View file

@ -0,0 +1,379 @@
//
use ratatui::{
layout::Alignment,
prelude::{Buffer, Rect},
style::Stylize as _,
symbols::border,
text::Line,
widgets::{block::Title, Block, Paragraph, Widget},
};
use git_next_core::{
git::{self, graph::Log, Commit},
ForgeAlias, RepoAlias, RepoBranches,
};
use std::{collections::BTreeMap, fmt::Display, time::Instant};
use crate::{server::actor::messages::ValidAppConfig, tui::components::ConfiguredAppWidget};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct State {
last_update: Instant,
started: Instant,
pub mode: ServerState,
}
impl State {
pub fn initial() -> Self {
Self {
last_update: Instant::now(),
started: Instant::now(),
mode: ServerState::Initial { tick: 0 },
}
}
pub fn tap(&mut self) {
self.last_update = Instant::now();
if let ServerState::Initial { tick } = &mut self.mode {
*tick += 1;
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ServerState {
/// UI has started but has no information on the state of the server
Initial { tick: usize }, // NOTE: for use with throbber-widgets-tui ?
/// The application configuration has been loaded, individual forges and repos have their own
/// states
Configured {
forges: BTreeMap<ForgeAlias, ForgeState>,
},
}
impl ServerState {
pub fn update_branches(
&mut self,
forge_alias: &ForgeAlias,
repo_alias: &RepoAlias,
branches: RepoBranches,
) {
if let Self::Configured { forges } = self {
let Some(forge_state) = forges.get_mut(forge_alias) else {
return;
};
let Some(repo_state) = forge_state.repos.get_mut(repo_alias) else {
return;
};
match repo_state {
RepoState::Configured {
branches: state_branches,
..
}
| RepoState::Ready {
branches: state_branches,
..
} => *state_branches = branches,
RepoState::Identified { .. } => (),
}
}
}
pub fn update_log(&mut self, forge_alias: &ForgeAlias, repo_alias: &RepoAlias, log: Log) {
if let Self::Configured { forges } = self {
let Some(forge_state) = forges.get_mut(forge_alias) else {
return;
};
let Some(repo_state) = forge_state.repos.get_mut(repo_alias) else {
return;
};
match repo_state {
RepoState::Ready { log: state_log, .. } => *state_log = log,
RepoState::Identified { .. } | RepoState::Configured { .. } => (),
}
}
}
}
impl From<ValidAppConfig> for ServerState {
fn from(app_config: ValidAppConfig) -> Self {
Self::Configured {
forges: app_config
.app_config
.forges()
.map(|(forge_alias, config)| {
(
forge_alias,
config
.repos()
.map(|(repo_alias, server_repo_config)| {
(repo_alias, server_repo_config.repo_config())
})
.map(
|(repo_alias, option_repo_config)| match option_repo_config {
Some(rc) => (
repo_alias.clone(),
RepoState::Configured {
repo_alias,
message: "configured".into(),
messages: Vec::new(),
alert: None,
branches: rc.branches().clone(),
histories: None,
},
),
None => (
repo_alias.clone(),
RepoState::Identified {
repo_alias,
message: "identified".into(),
messages: Vec::new(),
alert: None,
},
),
},
)
.collect::<Vec<_>>(),
)
})
.map(|(forge_alias, vec_repo_alias_state)| {
let forge_state: ForgeState = ForgeState {
alias: forge_alias.clone(),
view_state: ViewState::default(),
repos: vec_repo_alias_state.into_iter().collect::<BTreeMap<_, _>>(),
};
(forge_alias, forge_state)
})
.collect::<BTreeMap<_, _>>(),
}
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum ViewState {
Collapsed,
#[default]
Expanded,
}
impl Display for ViewState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let view_state = match self {
Self::Collapsed => "+",
Self::Expanded => "-",
};
write!(f, "{view_state}")
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ForgeState {
pub alias: ForgeAlias,
pub view_state: ViewState,
pub repos: BTreeMap<RepoAlias, RepoState>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RepoState {
Identified {
repo_alias: RepoAlias,
message: String,
messages: Vec<String>,
alert: Option<String>,
},
Configured {
repo_alias: RepoAlias,
message: String,
messages: Vec<String>,
alert: Option<String>,
branches: RepoBranches,
histories: Option<git::commit::Histories>,
},
Ready {
repo_alias: RepoAlias,
message: String,
messages: Vec<String>,
alert: Option<String>,
branches: RepoBranches,
histories: Option<git::commit::Histories>,
view_state: ViewState,
main: Commit,
next: Commit,
dev: Commit,
log: Log,
},
}
impl RepoState {
#[tracing::instrument]
pub fn update_branches(
&mut self,
branches: RepoBranches,
histories: Option<git::commit::Histories>,
) {
match self {
Self::Configured {
branches: state_branches,
histories: state_histories,
..
}
| Self::Ready {
branches: state_branches,
histories: state_histories,
..
} => {
*state_branches = branches;
*state_histories = histories;
}
Self::Identified { .. } => (),
}
}
#[tracing::instrument]
pub fn update_log(&mut self, log: Log) {
match self {
Self::Ready { log: state_log, .. } => {
*state_log = log;
}
Self::Identified { .. } | Self::Configured { .. } => (),
}
}
#[tracing::instrument]
pub fn update_message(&mut self, msg: impl Into<String> + std::fmt::Debug) {
tracing::info!("new tui message");
let msg: String = msg.into();
match self {
Self::Identified {
message, messages, ..
}
| Self::Configured {
message, messages, ..
}
| Self::Ready {
message, messages, ..
} => {
messages.push(msg.clone());
*message = msg;
}
}
}
#[tracing::instrument]
pub fn clear_alert(&mut self) {
match self {
Self::Identified { .. } | Self::Configured { .. } => (),
Self::Ready { alert, .. } => *alert = None,
}
}
#[tracing::instrument]
pub fn alert(&mut self, msg: impl Into<String> + std::fmt::Debug) {
let msg: String = msg.into();
tracing::info!(%msg, "new tui alert");
self.update_message("ALERT");
match self {
Self::Identified { alert, .. }
| Self::Configured { alert, .. }
| Self::Ready { alert, .. } => *alert = Some(msg),
}
}
pub(crate) fn ready(
repo_state: RepoState,
main: Commit,
next: Commit,
dev: Commit,
) -> RepoState {
match repo_state {
RepoState::Identified {
repo_alias,
message,
messages,
alert,
} => RepoState::Identified {
repo_alias,
message,
messages,
alert,
},
RepoState::Configured {
repo_alias,
message,
messages,
alert,
branches,
histories,
} => RepoState::Ready {
repo_alias,
message,
messages,
alert,
branches,
histories,
view_state: ViewState::Expanded,
main,
next,
dev,
log: Log::default(),
},
RepoState::Ready {
repo_alias,
message,
messages,
alert,
branches,
histories,
view_state,
log,
.. // drop existing main, next and dev to use parameters
} => RepoState::Ready {
repo_alias,
message,
messages,
alert,
branches,
histories,
view_state,
main,
next,
dev,
log,
},
}
}
}
impl Widget for &State {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let block = Block::bordered()
.title(Title::from(" Git-Next ".bold()).alignment(Alignment::Center))
.title(
Title::from(Line::from(vec![
" [q]uit ".into(),
format!(
"{}s ",
self.last_update.duration_since(self.started).as_secs()
)
.into(),
]))
.alignment(Alignment::Center)
.position(ratatui::widgets::block::Position::Bottom),
)
.border_set(border::THICK);
let interior = block.inner(area);
block.render(area, buf);
match &self.mode {
ServerState::Initial { tick } => Paragraph::new(format!("Loading...{tick}"))
.centered()
.render(interior, buf),
ServerState::Configured { forges } => {
ConfiguredAppWidget { forges }.render(interior, buf);
}
}
}
}

View file

@ -0,0 +1,52 @@
use std::collections::BTreeMap;
use git_next_core::ForgeAlias;
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
text::Line,
widgets::Widget,
};
use crate::tui::actor::{ForgeState, ViewState};
use super::forge::ForgeWidget;
pub struct ConfiguredAppWidget<'a> {
pub forges: &'a BTreeMap<ForgeAlias, ForgeState>,
}
impl<'a> Widget for ConfiguredAppWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let layout_app = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1), Constraint::Fill(1)])
.split(area);
Line::from(format!("Forges: ({})", self.forges.keys().len())).render(layout_app[0], buf);
let layout_forge_list = Layout::default()
.direction(Direction::Vertical)
.constraints(
self.forges
.iter()
.map(|(_alias, state)| match state.view_state {
ViewState::Collapsed => Constraint::Length(1),
ViewState::Expanded => Constraint::Fill(1),
}),
)
.split(layout_app[1]);
self.forges
.iter()
.map(|(forge_alias, state)| ForgeWidget {
forge_alias,
repos: &state.repos,
view_state: state.view_state,
})
.enumerate()
.for_each(|(i, w)| w.render(layout_forge_list[i], buf));
}
}

View file

@ -0,0 +1,15 @@
//
use git_next_core::ForgeAlias;
use ratatui::{buffer::Buffer, layout::Rect, text::Text, widgets::Widget};
pub struct CollapsedForgeWidget<'a> {
pub forge_alias: &'a ForgeAlias,
}
impl<'a> Widget for CollapsedForgeWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
Text::from(format!("- {}", self.forge_alias)).render(area, buf);
}
}

View file

@ -0,0 +1,46 @@
//
use std::collections::BTreeMap;
use git_next_core::{ForgeAlias, RepoAlias};
use ratatui::{
buffer::Buffer,
layout::{Alignment, Constraint, Direction, Layout, Rect},
widgets::{block::Title, Block, Borders, Widget},
};
use crate::tui::{actor::RepoState, components::repo::RepoWidget};
pub struct ExpandedForgeWidget<'a> {
pub forge_alias: &'a ForgeAlias,
pub repos: &'a BTreeMap<RepoAlias, RepoState>,
}
impl<'a> Widget for ExpandedForgeWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let block = Block::default()
.title(Title::from(self.forge_alias.to_string()).alignment(Alignment::Left))
.borders(Borders::ALL);
let layout_forge = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1), Constraint::Fill(1)])
.split(block.inner(area));
block.render(area, buf);
let layout_repo_list = Layout::default()
.direction(Direction::Vertical)
.constraints(
self.repos
.iter()
.map(|(_alias, _state)| Constraint::Fill(1)),
)
.split(layout_forge[1]);
self.repos
.values()
.map(|repo_state| RepoWidget { repo_state })
.enumerate()
.for_each(|(i, w)| w.render(layout_repo_list[i], buf));
}
}

View file

@ -0,0 +1,34 @@
//
mod collapsed;
mod expanded;
use std::collections::BTreeMap;
use git_next_core::{ForgeAlias, RepoAlias};
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
use crate::tui::actor::{RepoState, ViewState};
pub struct ForgeWidget<'a> {
pub forge_alias: &'a ForgeAlias,
pub repos: &'a BTreeMap<RepoAlias, RepoState>,
pub view_state: ViewState,
}
impl<'a> Widget for ForgeWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
match self.view_state {
ViewState::Collapsed => collapsed::CollapsedForgeWidget {
forge_alias: self.forge_alias,
}
.render(area, buf),
ViewState::Expanded => expanded::ExpandedForgeWidget {
forge_alias: self.forge_alias,
repos: self.repos,
}
.render(area, buf),
}
}
}

View file

@ -0,0 +1,17 @@
use ratatui::{
text::Line,
widgets::{Paragraph, Widget},
};
//
pub struct CommitLog<'a> {
pub histories: Option<&'a crate::git::commit::Histories>,
}
impl<'a> Widget for CommitLog<'a> {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
Paragraph::new(Line::from(vec!["todo".into()])).render(area, buf);
}
}

View file

@ -0,0 +1,7 @@
mod configured_app;
mod forge;
mod history;
mod repo;
pub use configured_app::ConfiguredAppWidget;
pub use history::CommitLog;

View file

@ -0,0 +1,40 @@
//
use git_next_core::{RepoAlias, RepoBranches};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
widgets::Widget,
};
use super::{identity::Identity, messages::Messages};
pub struct ConfiguredRepoWidget<'a> {
pub repo_alias: &'a RepoAlias,
pub message: &'a str,
pub messages: &'a Vec<String>,
pub alert: Option<&'a str>,
pub branches: &'a RepoBranches,
}
impl<'a> Widget for ConfiguredRepoWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1), Constraint::Fill(1)])
.split(area);
Identity::new(
self.repo_alias,
self.alert,
self.message,
Some(&self.branches.main()),
Some(&self.branches.next()),
Some(&self.branches.dev()),
)
.render(layout[0], buf);
Messages::new(self.messages).render(layout[1], buf);
}
}

View file

@ -0,0 +1,32 @@
//
use git_next_core::RepoAlias;
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
widgets::Widget,
};
use super::{identity::Identity, messages::Messages};
pub struct IdentifiedRepoWidget<'a> {
pub repo_alias: &'a RepoAlias,
pub message: &'a str,
pub messages: &'a Vec<String>,
pub alert: Option<&'a str>,
}
impl<'a> Widget for IdentifiedRepoWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1), Constraint::Fill(1)])
.split(area);
Identity::new(self.repo_alias, self.alert, self.message, None, None, None)
.render(layout[0], buf);
Messages::new(self.messages).render(layout[1], buf);
}
}

View file

@ -0,0 +1,56 @@
//
// Line::from(vec![format!("i- {repo_alias} (loading...) {alert} [{message}]").into()]),
use git_next_core::{BranchName, RepoAlias};
use ratatui::{text::Text, widgets::Widget};
pub struct Identity<'a> {
pub repo_alias: &'a RepoAlias,
pub alert: Option<&'a str>,
pub message: &'a str,
pub main: Option<&'a BranchName>,
pub next: Option<&'a BranchName>,
pub dev: Option<&'a BranchName>,
}
impl<'a> Identity<'a> {
pub const fn new(
repo_alias: &'a RepoAlias,
alert: Option<&'a str>,
message: &'a str,
main: Option<&'a BranchName>,
next: Option<&'a BranchName>,
dev: Option<&'a BranchName>,
) -> Self {
Self {
repo_alias,
alert,
message,
main,
next,
dev,
}
}
}
impl<'a> Widget for Identity<'a> {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
let Identity {
repo_alias,
alert,
main,
next,
dev,
message,
} = self;
let alert = alert.unwrap_or("");
let text = if let (Some(main), Some(next), Some(dev)) = (main, next, dev) {
format!("{repo_alias} {alert} ({main}/{next}/{dev}) [{message}]")
} else {
format!("{repo_alias} {alert} (_/_/_) [{message}]")
};
Text::from(text).render(area, buf);
}
}

View file

@ -0,0 +1,26 @@
//
use ratatui::{
text::{Line, Text},
widgets::Widget,
};
pub struct Messages<'a>(&'a Vec<String>);
impl<'a> Messages<'a> {
pub const fn new(messages: &'a Vec<String>) -> Self {
Self(messages)
}
}
impl<'a> Widget for Messages<'a> {
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
where
Self: Sized,
{
Text::from(
self.0
.iter()
.map(|m| Line::from(format!("- {m}")))
.collect::<Vec<_>>(),
)
.render(area, buf);
}
}

View file

@ -0,0 +1,84 @@
//
mod configured;
mod identified;
mod identity;
mod messages;
mod ready;
use std::string::String;
use configured::ConfiguredRepoWidget;
use identified::IdentifiedRepoWidget;
use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
use ready::ReadyRepoWidget;
use crate::tui::actor::RepoState;
pub struct RepoWidget<'a> {
pub repo_state: &'a RepoState,
}
impl<'a> Widget for RepoWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
match self.repo_state {
RepoState::Identified {
repo_alias,
message,
messages,
alert,
} => {
IdentifiedRepoWidget {
repo_alias,
message,
messages,
alert: alert.as_ref().map(String::as_str),
}
.render(area, buf);
}
RepoState::Configured {
repo_alias,
message,
messages,
alert,
branches,
..
} => ConfiguredRepoWidget {
repo_alias,
message,
messages,
alert: alert.as_ref().map(String::as_str),
branches,
}
.render(area, buf),
RepoState::Ready {
repo_alias,
message,
messages,
alert,
branches,
histories,
view_state,
main,
next,
dev,
..
} => ReadyRepoWidget {
repo_alias,
message,
messages,
alert: alert.as_ref().map(String::as_str),
branches,
histories: histories.as_ref(),
view_state,
main,
next,
dev,
}
.render(area, buf),
};
}
}

View file

@ -0,0 +1,58 @@
//
use git_next_core::{git::Commit, RepoAlias, RepoBranches};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
widgets::Widget,
};
use crate::{
git,
tui::{actor::ViewState, components::CommitLog},
};
use super::{identity::Identity, messages::Messages};
pub struct ReadyRepoWidget<'a> {
pub repo_alias: &'a RepoAlias,
pub message: &'a str,
pub messages: &'a Vec<String>,
pub alert: Option<&'a str>,
pub branches: &'a RepoBranches,
pub histories: Option<&'a git::commit::Histories>,
pub view_state: &'a ViewState,
pub main: &'a Commit,
pub next: &'a Commit,
pub dev: &'a Commit,
}
impl<'a> Widget for ReadyRepoWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Fill(1),
])
.split(area);
Identity::new(
self.repo_alias,
self.alert,
self.message,
Some(&self.branches.main()),
Some(&self.branches.next()),
Some(&self.branches.dev()),
)
.render(layout[0], buf);
Messages::new(self.messages).render(layout[1], buf);
CommitLog {
histories: self.histories,
}
.render(layout[2], buf);
}
}

View file

@ -0,0 +1,86 @@
//
use std::path::PathBuf;
use color_eyre::eyre::Result;
use directories::ProjectDirs;
use lazy_static::lazy_static;
use tracing_error::ErrorLayer;
use tracing_subscriber::{self, layer::SubscriberExt, util::SubscriberInitExt, Layer};
lazy_static! {
pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase();
pub static ref DATA_FOLDER: Option<PathBuf> =
std::env::var(format!("{}_DATA", PROJECT_NAME.clone()))
.ok()
.map(PathBuf::from);
pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", PROJECT_NAME.clone());
pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME"));
}
fn project_directory() -> Option<ProjectDirs> {
ProjectDirs::from("net", "kemitix", env!("CARGO_PKG_NAME"))
}
pub fn get_data_dir() -> PathBuf {
let directory = DATA_FOLDER.clone().map_or_else(
|| {
project_directory().map_or_else(
|| PathBuf::from(".").join(".data"),
|proj_dirs| proj_dirs.data_local_dir().to_path_buf(),
)
},
|data_folder| data_folder,
);
directory
}
pub fn initialize_logging() -> Result<()> {
let directory = get_data_dir();
std::fs::create_dir_all(directory.clone())?;
let log_path = directory.join(LOG_FILE.clone());
let log_file = std::fs::File::create(log_path)?;
std::env::set_var(
"RUST_LOG",
std::env::var("RUST_LOG")
.or_else(|_| std::env::var(LOG_ENV.clone()))
.unwrap_or_else(|_| format!("{}=info", env!("CARGO_CRATE_NAME"))),
);
let file_subscriber = tracing_subscriber::fmt::layer()
.with_file(true)
.with_line_number(true)
.with_writer(log_file)
.with_target(false)
.with_ansi(false)
.with_filter(tracing_subscriber::filter::EnvFilter::from_default_env());
tracing_subscriber::registry()
.with(file_subscriber)
.with(ErrorLayer::default())
.init();
Ok(())
}
/// Similar to the `std::dbg!` macro, but generates `tracing` events rather
/// than printing to stdout.
///
/// By default, the verbosity level for the generated events is `DEBUG`, but
/// this can be customized.
#[macro_export]
macro_rules! trace_dbg {
(target: $target:expr, level: $level:expr, $ex:expr) => {{
match $ex {
value => {
tracing::event!(target: $target, $level, ?value, stringify!($ex));
value
}
}
}};
(level: $level:expr, $ex:expr) => {
trace_dbg!(target: module_path!(), level: $level, $ex)
};
(target: $target:expr, $ex:expr) => {
trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex)
};
($ex:expr) => {
trace_dbg!(level: tracing::Level::DEBUG, $ex)
};
}

View file

@ -1,5 +1,7 @@
//
mod actor;
pub mod components;
pub mod logging;
pub use actor::messages::Tick;
pub use actor::Tui;

View file

@ -47,7 +47,8 @@ impl ServerRepoConfig {
}
/// Returns a `RepoConfig` from the server configuration if ALL THREE branches were provided
pub(crate) fn repo_config(&self) -> Option<RepoConfig> {
#[must_use]
pub fn repo_config(&self) -> Option<RepoConfig> {
match (&self.main, &self.next, &self.dev) {
(Some(main), Some(next), Some(dev)) => Some(RepoConfig::new(
RepoBranches::new(main.to_string(), next.to_string(), dev.to_string()),

View file

@ -2,7 +2,7 @@
use crate::config::{BranchName, RepoBranches};
use derive_more::Constructor;
#[derive(Debug, Constructor, derive_with::With)]
#[derive(Clone, Debug, Constructor, PartialEq, Eq, derive_with::With)]
pub struct Push {
branch: BranchName,
sha: String,
@ -34,7 +34,7 @@ impl Push {
}
}
#[derive(Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Branch {
Main,
Next,

View file

@ -62,7 +62,7 @@ newtype!(
"The commit message for a git commit."
);
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Histories {
pub main: Vec<Commit>,
pub next: Vec<Commit>,

View file

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

View file

@ -1,7 +1,7 @@
//
use crate::{git, git::repository::open::OpenRepositoryLike, BranchName};
#[derive(Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Force {
No,
From(git::GitRef),

View file

@ -94,7 +94,8 @@ pub trait OpenRepositoryLike: std::fmt::Debug + Sync {
///
/// # Errors
///
/// Will return `Err` if there are any network connectivity issues with the remote server.
/// Will return `Err` if there are any problems with the branch name being invalid, or any
/// corruption of the git repository.
fn commit_log(
&self,
branch_name: &BranchName,

View file

@ -1,3 +1,5 @@
use std::fmt::Display;
//
use crate::{git::Commit, BranchName, ForgeAlias, RepoAlias};
use serde_json::json;
@ -115,3 +117,18 @@ impl UserNotification {
}
}
}
impl Display for UserNotification {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = match self {
Self::CICheckFailed { commit, .. } => format!("CI Check Failed [{commit}]"),
Self::RepoConfigLoadFailure { reason, .. } => {
format!("Failed to load repo config: {reason}")
}
Self::WebhookRegistration { reason, .. } => {
format!("Failed to register webhook: {reason}")
}
Self::DevNotBasedOnMain { .. } => "Dev not based on main".to_string(),
};
write!(f, "{message}")
}
}

View file

@ -129,7 +129,13 @@ fn is_based_on(commits: &[git::commit::Commit], needle: &git::Commit) -> bool {
commits.iter().any(|commit| commit == needle)
}
fn get_commit_histories(
/// Returns the commit logs for the main, next and dev branches
///
/// # Errors
///
/// Will return `Err` if there are any problems with the branch names being invalid, or any
/// corruption of the git repository.
pub fn get_commit_histories(
open_repository: &dyn OpenRepositoryLike,
repo_config: &RepoConfig,
) -> git::commit::log::Result<git::commit::Histories> {