mod app;
mod columns_core;
mod config;
mod demo;
mod events;
mod filters_core;
mod gitlab;
mod models;
mod shortcuts_core;
mod storage;
mod ui;
mod utils;
use app::App;
use clap::Parser;
use crossterm::event::{self, Event, KeyEventKind};
use events::{handle_key_event, handle_mouse_event};
use gitlab::MAX_CONCURRENT_REQUESTS;
use models::AppEvent;
use std::sync::Arc;
use std::time::Duration;
use storage::{
get_or_prompt_token, load_or_create_config_async, load_state_async,
migrate_legacy_keyring_entry, resolve_active_project, save_state_async, ProjectEntry,
};
use tokio::sync::Semaphore;
#[derive(Parser, Debug)]
#[command(
name = "gitlab-tracker",
author,
version,
about,
long_about = None
)]
struct Args {
#[arg(long)]
demo: bool,
}
#[cfg(feature = "redmine")]
fn build_tracker_colors(
provider: &dyn gitlab_tracker_core::TrackerProvider,
) -> ui::tracker::TrackerLabelColors {
use config::parse_color;
use ui::tracker::TrackerLabelColors;
let maps = provider.label_colors();
let convert = |source: std::collections::HashMap<String, (String, String)>| {
source
.into_iter()
.map(|(k, (bg, fg))| (k, (parse_color(&bg), parse_color(&fg))))
.collect()
};
TrackerLabelColors {
tracker_type: convert(maps.tracker_type),
priority: convert(maps.priority),
}
}
fn init_logging() -> Option<tracing_appender::non_blocking::WorkerGuard> {
let log_dir = storage::get_save_dir()?;
std::fs::create_dir_all(&log_dir).ok()?;
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("gitlab-tracker")
.filename_suffix("log")
.max_log_files(10)
.build(&log_dir)
.ok()?;
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.with_writer(non_blocking)
.with_ansi(false)
.init();
Some(guard)
}
fn resolve_refresh_interval(config: &config::AppConfig) -> u64 {
std::env::var("GITLAB_REFRESH_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.or(config.refresh_interval_secs)
.unwrap_or(900)
}
fn apply_project_overrides(config: &mut config::AppConfig, project: &ProjectEntry) {
if let Some(branches) = &project.default_branches {
config.default_branches = branches.clone();
}
if let Some(prefixes) = &project.table_label_prefixes {
config.table_label_prefixes = prefixes.clone();
}
if let Some(profile) = &project.complexity_profile {
config.complexity_profile = profile.clone();
}
if let Some(secs) = project.refresh_interval_secs {
config.refresh_interval_secs = Some(secs);
}
if let Some(days) = project.activity_stale_days {
config.activity_stale_days = days;
}
if let Some(days) = project.activity_recent_days {
config.activity_recent_days = days;
}
if let Some(cols) = &project.visible_columns {
config.visible_columns = cols.clone();
}
if let Some(colors) = &project.label_colors {
config.label_colors = colors.clone();
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
ratatui::restore();
default_panic(info);
}));
if dotenvy::dotenv().is_err() {
if let Some(config_dir) = storage::get_save_dir() {
let global_env = config_dir.join(".env");
let _ = dotenvy::from_path(global_env);
}
}
let mut config = load_or_create_config_async().await;
if args.demo {
return demo::run_demo_mode(config).await;
}
let project = resolve_active_project().await;
apply_project_overrides(&mut config, &project);
let project_tracked_branches = project.tracked_branches.clone();
let base_url = project.gitlab_url;
let project_id = project.project_id;
let refresh_interval_secs = resolve_refresh_interval(&config);
migrate_legacy_keyring_entry(&base_url);
let token = get_or_prompt_token(&base_url).to_string();
#[cfg(feature = "redmine")]
let redmine_provider: Option<app::TrackerHandle> = {
use std::sync::Arc;
let tracker_cfg = project
.tracker
.as_ref()
.filter(|t| t.provider.eq_ignore_ascii_case("redmine"));
match tracker_cfg {
None => {
tracing::info!("No [project.tracker] section found — tracker integration disabled");
None
}
Some(cfg) => {
let mut redmine_cfg: gitlab_tracker_redmine::config::RedmineConfig =
cfg.extra.clone().try_into().unwrap_or_default();
redmine_cfg.url = cfg.url.clone();
redmine_cfg.apply_env_override();
if !redmine_cfg.is_active() {
tracing::warn!(
"[project.tracker] found but url is empty — integration disabled"
);
None
} else {
tracing::info!(url = %redmine_cfg.url, "Redmine integration active (from projects.toml)");
gitlab_tracker_redmine::keyring::get_or_prompt_token(&redmine_cfg.url).map(
|tok| {
let provider = gitlab_tracker_redmine::RedmineProvider::new(
redmine_cfg,
tok.to_string(),
);
Arc::new(provider) as Arc<dyn gitlab_tracker_core::TrackerProvider>
},
)
}
}
}
};
let _log_guard = init_logging();
crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)?;
let mut terminal = ratatui::init();
let (saved_mrs, migrated_branches, mut last_known_branches) =
load_state_async(&base_url, &project_id).await;
let mut app = App::new(
token,
project_id.clone(),
base_url.clone(),
refresh_interval_secs,
config,
);
app.branches = if let Some(ref tb) = project_tracked_branches {
if tb.is_empty() {
app.config.default_branches.clone()
} else {
tb.clone()
}
} else if !migrated_branches.is_empty() {
migrated_branches
} else {
app.config.default_branches.clone()
};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let api_semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_REQUESTS));
#[cfg(feature = "redmine")]
if let Some(ref provider) = redmine_provider {
app.tracker_colors = build_tracker_colors(provider.as_ref());
app.tracker = redmine_provider;
}
app.shortcut_providers = gitlab_tracker_core::collect_all_blocks();
app.restore_from_saved(saved_mrs, api_semaphore.clone(), tx.clone());
gitlab::spawn_milestones_fetch(app.fetch_context(), tx.clone());
gitlab::spawn_gitlab_labels_fetch(app.fetch_context(), tx.clone());
let tx_timer = tx.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
loop {
interval.tick().await;
let _ = tx_timer.send(AppEvent::Tick);
}
});
loop {
while let Ok(event) = rx.try_recv() {
let needs_save = app
.apply_event(event, api_semaphore.clone(), &tx, &mut last_known_branches)
.await;
if needs_save {
save_state_async(&app.mrs, &last_known_branches, &base_url, &project_id).await;
}
}
let mode_label = match app.input_mode {
app::InputMode::Editing => "✏️ Editing",
app::InputMode::ColumnPicker => "⚙️ Columns",
app::InputMode::FilterPicker => "🔍 Filter",
app::InputMode::LogTime => "⏱️ Log Time",
app::InputMode::Help => "❓ Help",
app::InputMode::Normal => "Normal",
};
let filter_label = app.active_filter.label(&app.filter_defs);
let window_title = format!(
"GitLab Tracker │ {} MRs │ {} │ {}",
app.mrs.len(),
mode_label,
filter_label,
);
crossterm::execute!(
std::io::stdout(),
crossterm::terminal::SetTitle(&window_title)
)?;
terminal.draw(|f| ui::render_ui(f, &mut app))?;
if event::poll(Duration::from_millis(50))? {
match event::read()? {
Event::Mouse(mouse) => {
let size = terminal.size()?;
handle_mouse_event(mouse, size.width, size.height, &mut app, &tx);
}
Event::Key(key)
if key.kind == KeyEventKind::Press
&& handle_key_event(
key,
&mut app,
&api_semaphore,
&tx,
&mut last_known_branches,
)
.await =>
{
break;
}
_ => {}
}
}
}
crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture)?;
ratatui::restore();
Ok(())
}