use std::{path::PathBuf, sync::mpsc::Sender};
use compact_str::CompactString;
use ratatui::layout::Rect;
use serde::{Deserialize, Serialize};
use tachyonfx::{Duration, RefRect};
use tracing::{debug, info, instrument, warn};
use crate::{
client::{ClientConfig, GitlabService},
config::save_config,
dispatcher::Dispatcher,
domain::Project,
effect_registry::EffectRegistry,
event::GlimEvent,
id::ProjectId,
input::{processor::NormalModeProcessor, InputMultiplexer},
logging::LoggingReloadHandle,
notice_service::{Notice, NoticeLevel, NoticeService},
result::GlimError,
stores::{log_event, ProjectStore},
ui::{widget::NotificationState, StatefulWidgets},
};
pub struct GlimApp {
running: bool,
config_path: PathBuf,
gitlab: GitlabService,
last_tick: std::time::Instant,
sender: Sender<GlimEvent>,
project_store: ProjectStore,
notices: NoticeService,
input: InputMultiplexer,
clipboard: arboard::Clipboard,
log_reload_handle: LoggingReloadHandle,
current_log_level: tracing::Level,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct GlimConfig {
pub gitlab_url: CompactString,
pub gitlab_token: CompactString,
pub search_filter: Option<CompactString>,
pub log_level: Option<CompactString>,
#[serde(default)]
pub animations: bool,
}
impl Default for GlimConfig {
fn default() -> Self {
Self {
gitlab_url: "https://".into(),
gitlab_token: "".into(),
search_filter: None,
log_level: Some("Error".into()),
animations: true,
}
}
}
impl GlimApp {
pub fn new(
sender: Sender<GlimEvent>,
config_path: PathBuf,
gitlab: GitlabService,
log_reload_handle: LoggingReloadHandle,
config: &GlimConfig,
) -> Self {
let mut input = InputMultiplexer::new(sender.clone());
input.push(Box::new(NormalModeProcessor::new(sender.clone())));
let current_log_level = config
.log_level
.as_ref()
.and_then(|level_str| level_str.parse().ok())
.unwrap_or(tracing::Level::ERROR);
Self {
running: true,
config_path,
gitlab,
last_tick: std::time::Instant::now(),
sender: sender.clone(),
project_store: ProjectStore::new(sender),
notices: NoticeService::new(),
input,
clipboard: arboard::Clipboard::new().expect("failed to create clipboard"),
log_reload_handle,
current_log_level,
}
}
#[instrument(skip(self, event, ui, effects), fields(event_type = %event.variant_name()))]
pub fn apply(
&mut self,
event: GlimEvent,
ui: &mut StatefulWidgets,
effects: &mut EffectRegistry,
) {
self.input.apply(&event, ui);
log_event(&event);
effects.apply(&event);
self.notices.apply(&event);
self.project_store.apply(&event);
match event {
GlimEvent::AppExit => self.running = false,
GlimEvent::ProjectOpenUrl(id) => {
debug!(project_id = %id, "Opening project in browser");
open::that(&self.project(id).url).expect("unable to open browser")
},
GlimEvent::PipelineOpenUrl(project_id, pipeline_id) => {
debug!(project_id = %project_id, pipeline_id = %pipeline_id, "Opening pipeline in browser");
let project = self.project(project_id);
let pipeline = project
.pipeline(pipeline_id)
.expect("pipeline not found");
open::that(&pipeline.url).expect("unable to open browser");
},
GlimEvent::JobOpenUrl(project_id, pipeline_id, job_id) => {
debug!(project_id = %project_id, pipeline_id = %pipeline_id, job_id = %job_id, "Opening job in browser");
let project = self.project(project_id);
let job_url = project
.pipeline(pipeline_id)
.and_then(|p| p.job(job_id))
.map(|job| &job.url)
.expect("job not found");
open::that(job_url).expect("unable to open browser");
},
GlimEvent::JobLogFetch(project_id, pipeline_id) => {
debug!(project_id = %project_id, pipeline_id = %pipeline_id, "Downloading error log");
let project = self.project(project_id);
let pipeline = project
.pipeline(pipeline_id)
.expect("pipeline not found");
let job = pipeline
.failed_job()
.expect("no failed job found");
self.gitlab
.spawn_download_job_log(project_id, job.id);
},
GlimEvent::JobLogDownloaded(project_id, job_id, trace) => {
info!(project_id = %project_id, job_id = %job_id, trace_length = trace.len(), "Job log downloaded and copied to clipboard");
self.clipboard.set_text(trace).unwrap();
},
GlimEvent::JobsActiveFetch => {
debug!("Requesting active jobs for all projects");
self.project_store
.sorted_projects()
.iter()
.flat_map(|p| p.pipelines.iter())
.flatten()
.filter(|p| p.status.is_active() || p.has_active_jobs())
.for_each(|p| self.gitlab.spawn_fetch_jobs(p.project_id, p.id));
},
GlimEvent::PipelinesFetch(id) => {
debug!(project_id = %id, "Requesting pipelines for project");
self.gitlab.spawn_fetch_pipelines(id, None)
},
GlimEvent::ProjectsFetch => {
let latest_activity = self
.project_store
.sorted_projects()
.iter()
.max_by_key(|p| p.last_activity_at)
.map(|p| p.last_activity_at);
let updated_after = self
.project_store
.sorted_projects()
.iter()
.filter(|p| p.has_active_pipelines())
.min_by_key(|p| p.last_activity_at)
.map(|p| p.last_activity_at)
.map_or_else(|| latest_activity, Some);
self.gitlab.spawn_fetch_projects(updated_after)
},
GlimEvent::JobsFetch(project_id, pipeline_id) => {
debug!(project_id = %project_id, pipeline_id = %pipeline_id, "Requesting jobs for pipeline");
self.gitlab
.spawn_fetch_jobs(project_id, pipeline_id)
},
GlimEvent::ConfigUpdate(config) => {
let client_config = ClientConfig::from(config.clone())
.with_debug_logging(self.gitlab.config().debug.log_responses);
let _ = self.gitlab.update_config(client_config);
if let Some(ref log_level_str) = config.log_level {
self.update_logging_level(log_level_str);
}
},
GlimEvent::LogLevelChanged(level) => {
info!("Log level changed to: {:?}", level);
},
GlimEvent::ConfigApply => {
if let Some(config_popup) = ui.config_popup_state.as_ref() {
let config = config_popup.to_config();
let client_config = ClientConfig::from(config.clone())
.with_debug_logging(self.gitlab.config().debug.log_responses);
if let Err(validation_error) = client_config.validate() {
let glim_error = GlimError::from(&validation_error);
self.dispatch(GlimEvent::AppError(glim_error));
return;
}
match self.gitlab.update_config(client_config) {
Ok(_) => {
save_config(&self.config_path, config.clone())
.expect("failed to save config");
self.dispatch(GlimEvent::ConfigUpdate(config));
self.dispatch(GlimEvent::ConfigClose);
self.dispatch(GlimEvent::ProjectsFetch);
},
Err(e) => {
let glim_error = GlimError::config_connection_error(e.to_string());
self.dispatch(GlimEvent::AppError(glim_error));
},
}
}
},
GlimEvent::NotificationLast => {
if let Some(notice) = self.notices.last_notification() {
let content_area = RefRect::new(Rect::default());
effects.register_notification_effect(content_area.clone());
ui.notice = Some(NotificationState::new(
notice.clone(),
&self.project_store,
content_area,
));
}
},
GlimEvent::NotificationDismiss => {
ui.notice = None;
},
GlimEvent::FilterMenuShow => {
ui.filter_input_active = true;
},
GlimEvent::ScreenCapture => {
debug!("Screen capture requested");
ui.capture_screen_requested = true;
},
GlimEvent::ScreenCaptureToClipboard(ansi_string) => {
debug!("Copying screen capture to clipboard");
match self.clipboard.set_text(ansi_string) {
Ok(_) => {
info!("Screen buffer captured and copied to clipboard");
},
Err(e) => {
warn!(error = %e, "Failed to copy screen capture to clipboard");
},
}
},
_ => {},
}
if self.notices.has_error()
&& ui
.notice
.as_ref()
.map(|n| n.notice.level == NoticeLevel::Info)
.unwrap_or(false)
{
ui.notice = None;
}
if ui.notice.is_none() {
if let Some(notice) = self.pop_notice() {
let content_area = RefRect::new(Rect::default());
effects.register_notification_effect(content_area.clone());
ui.notice = Some(NotificationState::new(
notice,
&self.project_store,
content_area,
));
}
}
}
pub fn load_config(&self) -> Result<GlimConfig, GlimError> {
let config_file = &self.config_path;
if config_file.exists() {
let config: GlimConfig = confy::load_path(config_file)
.map_err(|e| GlimError::config_load_error(config_file.clone(), e))?;
Ok(config)
} else {
Err(GlimError::config_file_not_found(config_file.clone()))
}
}
pub fn process_timers(&mut self) -> Duration {
let now = std::time::Instant::now();
let elapsed = now - self.last_tick;
self.last_tick = now;
Duration::from_millis(elapsed.as_millis() as u32)
}
pub fn project(&self, id: ProjectId) -> &Project {
self.project_store
.find(id)
.expect("project not found")
}
pub fn projects(&self) -> &[Project] {
self.project_store.sorted_projects()
}
pub fn filtered_projects(
&self,
temporary_filter: &Option<CompactString>,
) -> (Vec<Project>, Vec<usize>) {
let all_projects = self.project_store.sorted_projects();
if let Some(filter) = temporary_filter {
if !filter.trim().is_empty() {
let filter_lower = filter.to_lowercase();
let mut filtered_projects = Vec::new();
let mut filtered_indices = Vec::new();
for (index, project) in all_projects.iter().enumerate() {
if project
.path
.to_lowercase()
.contains(filter_lower.as_str())
|| project
.description
.as_ref()
.is_some_and(|d| d.to_lowercase().contains(filter_lower.as_str()))
{
filtered_projects.push(project.clone());
filtered_indices.push(index);
}
}
return (filtered_projects, filtered_indices);
}
}
(all_projects.to_vec(), (0..all_projects.len()).collect())
}
pub fn sender(&self) -> Sender<GlimEvent> {
self.sender.clone()
}
pub fn is_running(&self) -> bool {
self.running
}
pub fn pop_notice(&mut self) -> Option<Notice> {
self.notices.pop_notice()
}
fn update_logging_level(&mut self, log_level_str: &str) {
let level = match log_level_str.to_lowercase().as_str() {
"error" => tracing::Level::ERROR,
"warn" => tracing::Level::WARN,
"info" => tracing::Level::INFO,
"debug" => tracing::Level::DEBUG,
"trace" => tracing::Level::TRACE,
_ => {
warn!(
"Invalid log level: {}, keeping current level",
log_level_str
);
return;
},
};
if level != self.current_log_level {
info!(
"Updating log level from {:?} to {:?}",
self.current_log_level, level
);
self.log_reload_handle.update_levels(level, level);
self.current_log_level = level;
self.dispatch(GlimEvent::LogLevelChanged(level));
}
}
}
impl Dispatcher for GlimApp {
fn dispatch(&self, event: GlimEvent) {
self.sender.send(event).unwrap_or(());
}
}
#[allow(unused)]
pub fn modulo(a: u32, b: u32) -> u32 {
if b == 0 {
return 0;
}
let a = a as i32;
let b = b as i32;
((a % b) + b) as u32 % b as u32
}
pub trait Modulo {
fn modulo(self, b: Self) -> Self;
}
impl Modulo for i32 {
fn modulo(self, b: i32) -> i32 {
if b == 0 {
return 0;
}
((self % b) + b) % b
}
}
impl Modulo for u32 {
fn modulo(self, b: u32) -> u32 {
if b == 0 {
return 0;
}
(self as i32).modulo(b as i32) as u32
}
}
impl Modulo for isize {
fn modulo(self, b: isize) -> isize {
if b == 0 {
return 0;
}
((self % b) + b) % b
}
}
impl Modulo for usize {
fn modulo(self, b: usize) -> usize {
if b == 0 {
return 0;
}
(self as isize).modulo(b as isize) as usize
}
}