use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use ratatui::Frame;
use ratatui::layout::Position;
use ratatui::layout::Rect;
use tui_pane::Hittable;
use tui_pane::RenderFocus;
use tui_pane::Renderable;
use tui_pane::ToastTaskId;
use tui_pane::Viewport;
#[cfg(test)]
use crate::ci::CiRun;
use crate::ci::CiStatus;
use crate::project::AbsolutePath;
use crate::project::CheckoutInfo;
use crate::project::ProjectCiInfo;
use crate::project::RepoInfo;
use crate::tui::app::CiRunDisplayMode;
use crate::tui::pane::HoverTarget;
use crate::tui::pane::PaneRenderCtx;
use crate::tui::panes;
use crate::tui::panes::CiData;
use crate::tui::panes::PaneId;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CiDisplay {
#[default]
NoWorkflow,
UnpublishedBranch,
NoRuns,
Runs {
ci_status: Option<CiStatus>,
local: usize,
github_total: u32,
},
}
pub struct Ci {
pub fetch_tracker: CiFetchTracker,
fetch_toast: Option<ToastTaskId>,
display_modes: HashMap<AbsolutePath, CiRunDisplayMode>,
pub viewport: Viewport,
pub focus: RenderFocus,
content: Option<CiData>,
}
impl Ci {
pub fn new() -> Self {
Self {
fetch_tracker: CiFetchTracker::default(),
fetch_toast: None,
display_modes: HashMap::new(),
viewport: Viewport::new(),
focus: RenderFocus::inactive(),
content: None,
}
}
pub const fn content(&self) -> Option<&CiData> { self.content.as_ref() }
pub fn set_content(&mut self, data: CiData) { self.content = Some(data); }
pub fn clear_content(&mut self) { self.content = None; }
#[cfg(test)]
pub fn override_runs_for_test(&mut self, runs: Vec<CiRun>) {
if let Some(ci) = self.content.as_mut() {
ci.runs = runs;
ci.mode_label = None;
}
}
pub const fn set_fetch_toast(&mut self, task_id: Option<ToastTaskId>) {
self.fetch_toast = task_id;
}
pub const fn take_fetch_toast(&mut self) -> Option<ToastTaskId> { self.fetch_toast.take() }
pub fn display_mode_for(&self, path: &Path) -> CiRunDisplayMode {
self.display_modes.get(path).copied().unwrap_or_default()
}
pub fn display_mode_label_for(&self, path: &Path) -> &'static str {
match self.display_mode_for(path) {
CiRunDisplayMode::BranchOnly => "branch",
CiRunDisplayMode::All => "all",
}
}
pub fn set_display_mode(&mut self, path: AbsolutePath, mode: CiRunDisplayMode) {
self.display_modes.insert(path, mode);
}
pub fn remove_display_mode(&mut self, path: &Path) { self.display_modes.remove(path); }
pub fn clear_display_modes(&mut self) { self.display_modes.clear(); }
}
pub struct CiStatusLookup {
display_modes: HashMap<PathBuf, CiRunDisplayMode>,
}
impl CiStatusLookup {
pub fn display_mode_for(&self, path: &Path) -> CiRunDisplayMode {
self.display_modes.get(path).copied().unwrap_or_default()
}
}
impl Ci {
#[must_use]
pub fn status_lookup(&self) -> CiStatusLookup {
CiStatusLookup {
display_modes: self
.display_modes
.iter()
.map(|(path, mode)| (path.as_path().to_path_buf(), *mode))
.collect(),
}
}
#[allow(
clippy::too_many_arguments,
reason = "wide CI dependency surface (Q6 in docs/app-api.md)"
)]
pub fn package_display(
&self,
abs: &AbsolutePath,
repo_info: Option<&RepoInfo>,
git_info: Option<&CheckoutInfo>,
ci_info: Option<&ProjectCiInfo>,
latest_conclusion: Option<CiStatus>,
is_worktree_group: bool,
) -> CiDisplay {
let _ = is_worktree_group;
let has_workflows = repo_info.is_some_and(|r| r.workflows.is_present());
if !has_workflows {
return CiDisplay::NoWorkflow;
}
if Self::is_unpublished_branch(git_info, repo_info) {
return CiDisplay::UnpublishedBranch;
}
let Some(info) = ci_info else {
return CiDisplay::NoRuns;
};
let display_mode = self.display_mode_for(abs.as_path());
let local = Self::filtered_run_count(info, git_info, display_mode);
let github_total = info.github_total;
if local == 0 && github_total == 0 {
CiDisplay::NoRuns
} else {
CiDisplay::Runs {
ci_status: latest_conclusion,
local,
github_total,
}
}
}
fn is_unpublished_branch(
git_info: Option<&CheckoutInfo>,
repo_info: Option<&RepoInfo>,
) -> bool {
let Some(git) = git_info else {
return false;
};
let default_branch = repo_info.and_then(|r| r.default_branch.as_deref());
git.primary_tracked_ref().is_none() && git.head.branch_name() != default_branch
}
fn filtered_run_count(
info: &ProjectCiInfo,
git_info: Option<&CheckoutInfo>,
display_mode: CiRunDisplayMode,
) -> usize {
let Some(branch) = git_info.and_then(|g| g.head.branch_name()) else {
return info.runs.len();
};
if matches!(display_mode, CiRunDisplayMode::All) {
return info.runs.len();
}
info.runs.iter().filter(|run| run.branch == branch).count()
}
}
#[derive(Default)]
pub struct CiFetchTracker {
inner: HashSet<AbsolutePath>,
}
impl CiFetchTracker {
pub fn start(&mut self, path: AbsolutePath) { self.inner.insert(path); }
pub fn complete(&mut self, path: &Path) -> bool { self.inner.remove(path) }
pub fn is_fetching(&self, path: &Path) -> bool { self.inner.contains(path) }
pub fn clear(&mut self) { self.inner.clear(); }
pub fn retain(&mut self, mut keep: impl FnMut(&AbsolutePath) -> bool) {
self.inner.retain(|path| keep(path));
}
}
impl Renderable<PaneRenderCtx<'_>> for Ci {
fn render(&mut self, frame: &mut Frame<'_>, area: Rect, ctx: &PaneRenderCtx<'_>) {
panes::render_ci_pane_body(frame, area, self, ctx);
}
}
impl Hittable<HoverTarget> for Ci {
fn hit_test_at(&self, pos: Position) -> Option<HoverTarget> {
let row = panes::hit_test_table_row(&self.viewport, pos)?;
Some(HoverTarget::PaneRow {
pane: PaneId::CiRuns,
row,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_workflow_when_repo_info_missing() {
let ci = Ci::new();
let abs = AbsolutePath::from(std::path::Path::new("/abs/x"));
let display = ci.package_display(&abs, None, None, None, None, false);
assert_eq!(display, CiDisplay::NoWorkflow);
}
#[test]
fn default_is_no_workflow() {
assert_eq!(CiDisplay::default(), CiDisplay::NoWorkflow);
}
}