workmux 0.1.215

An opinionated workflow tool that orchestrates git worktrees and tmux
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;

use crate::github::{CheckMeta, CheckState, PrSummary};
use crate::nerdfont;
use crate::ui::theme::ThemePalette;

pub const SPINNER_FRAMES: &[char] = &['', '', '', '', '', '', '', '', '', ''];

#[derive(Debug, Clone, Copy)]
pub struct PrStatusOptions {
    pub include_number: bool,
    pub show_check_counts: bool,
    pub none_placeholder: Option<&'static str>,
    pub is_stale: bool,
}

pub fn format_pr_status(
    pr: Option<&PrSummary>,
    options: PrStatusOptions,
    spinner_frame: u8,
    palette: &ThemePalette,
) -> Vec<(String, Style)> {
    match pr {
        Some(pr) => format_pr_status_present(pr, options, spinner_frame, palette),
        None => options
            .none_placeholder
            .map(|placeholder| vec![(placeholder.to_string(), Style::default().fg(palette.dimmed))])
            .unwrap_or_default(),
    }
}

pub fn format_pr_status_present(
    pr: &PrSummary,
    options: PrStatusOptions,
    spinner_frame: u8,
    palette: &ThemePalette,
) -> Vec<(String, Style)> {
    let (icon, color) = pr_state_icon_color(pr, palette);
    let color = if options.is_stale {
        palette.dimmed
    } else {
        color
    };
    let mut spans = Vec::new();

    if options.include_number {
        spans.push((format!("#{} ", pr.number), Style::default().fg(color)));
    }
    spans.push((icon.to_string(), Style::default().fg(color)));

    if let Some(ref checks) = pr.checks {
        let check_icons = nerdfont::check_icons();
        let (check_icon, check_color, counts) = match checks {
            CheckState::Success => (check_icons.success.to_string(), palette.success, None),
            CheckState::Failure { passed, total } => (
                check_icons.failure.to_string(),
                palette.danger,
                Some((*passed, *total)),
            ),
            CheckState::Pending { passed, total } => {
                let frame = SPINNER_FRAMES[spinner_frame as usize % SPINNER_FRAMES.len()];
                (frame.to_string(), palette.accent, Some((*passed, *total)))
            }
        };
        let check_color = if options.is_stale {
            palette.dimmed
        } else {
            check_color
        };

        spans.push((" ".to_string(), Style::default()));
        spans.push((check_icon, Style::default().fg(check_color)));

        if options.show_check_counts
            && let Some((passed, total)) = counts
        {
            spans.push((
                format!(" {}/{}", passed, total),
                Style::default().fg(check_color),
            ));
        }

        if let Some(time_str) = format_check_elapsed(checks, pr.check_meta.as_ref()) {
            spans.push((
                format!(" {}", time_str),
                Style::default().fg(palette.dimmed),
            ));
        }
    }

    if options.is_stale {
        for (_, style) in &mut spans {
            *style = style.fg(palette.dimmed).add_modifier(Modifier::DIM);
        }
    }

    spans
}

pub fn pr_state_icon_color(
    pr: &PrSummary,
    palette: &ThemePalette,
) -> (&'static str, ratatui::style::Color) {
    let icons = nerdfont::pr_icons();
    if pr.is_draft {
        (icons.draft, palette.dimmed)
    } else {
        match pr.state.as_str() {
            "OPEN" => (icons.open, palette.success),
            "MERGED" => (icons.merged, palette.accent),
            "CLOSED" => (icons.closed, palette.danger),
            _ => ("?", palette.dimmed),
        }
    }
}

pub fn format_pr_details(
    pr: &PrSummary,
    spinner_frame: u8,
    palette: &ThemePalette,
) -> Vec<Span<'static>> {
    let Some(checks) = &pr.checks else {
        return vec![];
    };

    match checks {
        CheckState::Failure { .. } => {
            let Some(meta) = &pr.check_meta else {
                return vec![];
            };
            let Some(name) = &meta.failing_name else {
                return vec![];
            };
            let icon = nerdfont::check_icons().failure;
            vec![Span::styled(
                format!("{} {}", icon, name),
                Style::default().fg(palette.danger),
            )]
        }
        CheckState::Pending { .. } => match format_check_elapsed(checks, pr.check_meta.as_ref()) {
            Some(time_str) => {
                let frame = SPINNER_FRAMES[spinner_frame as usize % SPINNER_FRAMES.len()];
                vec![Span::styled(
                    format!("{} {}", frame, time_str),
                    Style::default().fg(palette.dimmed),
                )]
            }
            None => vec![],
        },
        CheckState::Success => vec![],
    }
}

fn format_check_elapsed(checks: &CheckState, meta: Option<&CheckMeta>) -> Option<String> {
    let meta = meta?;
    match checks {
        CheckState::Pending { .. } => {
            let start = meta.started_at?;
            let now = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .ok()?
                .as_secs();
            Some(format_compact_duration(now.saturating_sub(start)))
        }
        _ => None,
    }
}

fn format_compact_duration(secs: u64) -> String {
    if secs < 60 {
        format!("{}s", secs)
    } else if secs < 3600 {
        format!("{}m", secs / 60)
    } else if secs < 86400 {
        format!("{}h", secs / 3600)
    } else {
        format!("{}d", secs / 86400)
    }
}