use std::collections::BTreeMap;
use crate::deliverable::Deliverable;
use crate::project::Project;
use crate::tui::coordinator::nav::ListNav;
mod modals;
pub use modals::{
ConfigFormField, ConfigFormFocus, ConfigFormTags, ConfigFormView, DeliverableView,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Pane {
#[default]
Projects,
Sessions,
Activity,
}
impl Pane {
pub fn next(self) -> Self {
match self {
Pane::Projects => Pane::Sessions,
Pane::Sessions => Pane::Activity,
Pane::Activity => Pane::Projects,
}
}
pub fn prev(self) -> Self {
match self {
Pane::Projects => Pane::Activity,
Pane::Sessions => Pane::Projects,
Pane::Activity => Pane::Sessions,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ProjectRow {
pub name: String,
pub repo_url: String,
pub live_count: usize,
pub total_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SessionRow {
pub id: String,
pub short_id: String,
pub name: String,
pub branch: Option<String>,
pub task: Option<String>,
pub state: String,
pub pending_decision: Option<String>,
pub proposed_default: Option<String>,
pub deliverable_id: Option<String>,
pub unresumable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActivityInfo {
pub session_id: String,
pub state: String,
pub summary: String,
pub pending_decision: Option<String>,
pub proposed_default: Option<String>,
pub raw_pane_tail: Vec<String>,
pub stale: bool,
}
pub(crate) const ACTIVITY_UNAVAILABLE_THRESHOLD: u8 = 3;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ActivityFailureStreak {
session_id: Option<String>,
consecutive_failures: u8,
}
impl ActivityFailureStreak {
pub(crate) fn record_failure(&mut self, session_id: &str) {
if self.session_id.as_deref() != Some(session_id) {
self.session_id = Some(session_id.to_string());
self.consecutive_failures = 0;
}
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
}
pub(crate) fn reset(&mut self) {
self.session_id = None;
self.consecutive_failures = 0;
}
fn is_unavailable_for(&self, session_id: &str) -> bool {
self.session_id.as_deref() == Some(session_id)
&& self.consecutive_failures >= ACTIVITY_UNAVAILABLE_THRESHOLD
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmKind {
Kill,
Decommission,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingConfirm {
pub kind: ConfirmKind,
pub session_id: String,
pub session_label: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliverableLinkState {
Unknown,
Resolved,
Dangling,
}
#[derive(Debug, Clone, Default)]
pub struct ProjectCtlState {
pub projects: Vec<ProjectRow>,
pub projects_nav: ListNav,
pub sessions_by_project: BTreeMap<String, Vec<SessionRow>>,
pub sessions_nav: ListNav,
pub focus: Pane,
pub daemon_reachable: bool,
pub notice: Option<String>,
pub pending_confirm: Option<PendingConfirm>,
pub activity: Option<ActivityInfo>,
pub(crate) activity_failures: ActivityFailureStreak,
pub deliverables: Option<Vec<Deliverable>>,
pub deliverable_view: Option<DeliverableView>,
pub projects_full: BTreeMap<String, Project>,
pub config_form: Option<ConfigFormView>,
pub(crate) needs_repoll: bool,
pub should_exit: bool,
}
impl ProjectCtlState {
pub fn selected_project(&self) -> Option<&ProjectRow> {
self.projects.get(self.projects_nav.selected())
}
pub fn selected_project_name(&self) -> Option<&str> {
self.selected_project().map(|p| p.name.as_str())
}
pub fn current_sessions(&self) -> &[SessionRow] {
self.selected_project_name()
.and_then(|name| self.sessions_by_project.get(name))
.map(Vec::as_slice)
.unwrap_or(&[])
}
pub fn selected_session(&self) -> Option<&SessionRow> {
self.current_sessions().get(self.sessions_nav.selected())
}
pub fn activity_for_selected(&self) -> Option<&ActivityInfo> {
let selected_id = &self.selected_session()?.id;
self.activity
.as_ref()
.filter(|a| &a.session_id == selected_id)
}
pub fn activity_unavailable_for_selected(&self) -> bool {
let Some(selected_id) = self.selected_session().map(|s| s.id.as_str()) else {
return false;
};
self.activity_failures.is_unavailable_for(selected_id)
}
pub fn on_project_selection_changed(&mut self) {
self.sessions_nav = ListNav::default();
self.sessions_nav.sync_len(self.current_sessions().len());
self.deliverables = None;
}
pub fn cycle_focus_next(&mut self) {
self.focus = self.focus.next();
}
pub fn cycle_focus_prev(&mut self) {
self.focus = self.focus.prev();
}
pub fn drill_into_sessions(&mut self) {
self.focus = Pane::Sessions;
}
pub fn set_notice(&mut self, msg: impl Into<String>) {
self.notice = Some(msg.into());
}
pub fn clear_notice(&mut self) {
self.notice = None;
}
pub fn request_confirm(
&mut self,
kind: ConfirmKind,
session_id: impl Into<String>,
session_label: impl Into<String>,
) {
self.pending_confirm = Some(PendingConfirm {
kind,
session_id: session_id.into(),
session_label: session_label.into(),
});
}
pub fn clear_confirm(&mut self) {
self.pending_confirm = None;
}
pub fn deliverable_link_state(&self, id: &str) -> DeliverableLinkState {
match &self.deliverables {
None => DeliverableLinkState::Unknown,
Some(list) => {
if list.iter().any(|d| d.id.to_string() == id) {
DeliverableLinkState::Resolved
} else {
DeliverableLinkState::Dangling
}
}
}
}
pub fn request_repoll(&mut self) {
self.needs_repoll = true;
}
pub fn take_repoll(&mut self) -> bool {
std::mem::take(&mut self.needs_repoll)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(name: &str) -> ProjectRow {
ProjectRow {
name: name.to_string(),
repo_url: format!("https://github.com/acme/{name}"),
live_count: 0,
total_count: 0,
}
}
fn session(id: &str) -> SessionRow {
SessionRow {
id: id.to_string(),
short_id: id.chars().take(8).collect(),
name: format!("session-{id}"),
branch: Some("main".to_string()),
task: Some("do the thing".to_string()),
state: "active".to_string(),
pending_decision: None,
proposed_default: None,
deliverable_id: None,
unresumable: false,
}
}
#[test]
fn pane_cycle_wraps_forward_and_backward() {
assert_eq!(Pane::Projects.next(), Pane::Sessions);
assert_eq!(Pane::Sessions.next(), Pane::Activity);
assert_eq!(Pane::Activity.next(), Pane::Projects);
assert_eq!(Pane::Projects.prev(), Pane::Activity);
assert_eq!(Pane::Activity.prev(), Pane::Sessions);
assert_eq!(Pane::Sessions.prev(), Pane::Projects);
}
#[test]
fn selected_project_reads_nav_index() {
let mut state = ProjectCtlState {
projects: vec![row("a"), row("b")],
..Default::default()
};
state.projects_nav.sync_len(state.projects.len());
assert_eq!(state.selected_project().unwrap().name, "a");
state.projects_nav.down();
assert_eq!(state.selected_project().unwrap().name, "b");
}
#[test]
fn current_sessions_follows_selected_project() {
let mut state = ProjectCtlState {
projects: vec![row("a"), row("b")],
..Default::default()
};
state
.sessions_by_project
.insert("a".to_string(), vec![session("aaaaaaaa1111")]);
state.sessions_by_project.insert("b".to_string(), vec![]);
state.projects_nav.sync_len(state.projects.len());
assert_eq!(state.current_sessions().len(), 1);
state.projects_nav.down();
assert!(state.current_sessions().is_empty());
}
#[test]
fn project_switch_resets_session_selection() {
let mut state = ProjectCtlState {
projects: vec![row("a"), row("b")],
..Default::default()
};
state.sessions_by_project.insert(
"a".to_string(),
vec![session("id1111111111"), session("id2222222222")],
);
state.projects_nav.sync_len(state.projects.len());
state.sessions_nav.sync_len(state.current_sessions().len());
state.sessions_nav.down();
assert_eq!(state.sessions_nav.selected(), 1);
state.on_project_selection_changed();
assert_eq!(state.sessions_nav.selected(), 0);
}
#[test]
fn project_switch_resets_deliverables_to_unknown() {
let mut state = ProjectCtlState {
deliverables: Some(vec![]),
..Default::default()
};
assert!(state.deliverables.is_some());
state.on_project_selection_changed();
assert!(
state.deliverables.is_none(),
"switching projects must reset deliverables to Unknown, not carry over the \
previous project's (possibly stale) list"
);
}
#[test]
fn notice_set_and_clear() {
let mut state = ProjectCtlState::default();
assert!(state.notice.is_none());
state.set_notice("hello");
assert_eq!(state.notice.as_deref(), Some("hello"));
state.clear_notice();
assert!(state.notice.is_none());
}
#[test]
fn repoll_request_is_take_once() {
let mut state = ProjectCtlState::default();
assert!(!state.take_repoll());
state.request_repoll();
assert!(state.take_repoll());
assert!(!state.take_repoll());
}
#[test]
fn confirm_request_set_and_clear() {
let mut state = ProjectCtlState::default();
assert!(state.pending_confirm.is_none());
state.request_confirm(ConfirmKind::Decommission, "sess-1", "my-session");
let confirm = state.pending_confirm.clone().expect("confirm requested");
assert_eq!(confirm.kind, ConfirmKind::Decommission);
assert_eq!(confirm.session_id, "sess-1");
assert_eq!(confirm.session_label, "my-session");
state.clear_confirm();
assert!(state.pending_confirm.is_none());
}
fn activity(session_id: &str) -> ActivityInfo {
ActivityInfo {
session_id: session_id.to_string(),
state: "working".to_string(),
summary: "running tests".to_string(),
pending_decision: None,
proposed_default: None,
raw_pane_tail: vec!["$ cargo test".to_string()],
stale: false,
}
}
#[test]
fn activity_for_selected_matches_by_session_id() {
let mut state = ProjectCtlState {
projects: vec![row("a")],
..Default::default()
};
state
.sessions_by_project
.insert("a".to_string(), vec![session("sess-1")]);
state.projects_nav.sync_len(state.projects.len());
state.sessions_nav.sync_len(state.current_sessions().len());
state.activity = Some(activity("sess-1"));
assert_eq!(
state.activity_for_selected().unwrap().summary,
"running tests"
);
}
#[test]
fn activity_for_selected_is_none_when_it_targets_a_different_session() {
let mut state = ProjectCtlState {
projects: vec![row("a")],
..Default::default()
};
state
.sessions_by_project
.insert("a".to_string(), vec![session("sess-1")]);
state.projects_nav.sync_len(state.projects.len());
state.sessions_nav.sync_len(state.current_sessions().len());
state.activity = Some(activity("some-other-session"));
assert!(state.activity_for_selected().is_none());
}
#[test]
fn activity_for_selected_is_none_with_no_selection() {
let state = ProjectCtlState {
activity: Some(activity("sess-1")),
..Default::default()
};
assert!(state.activity_for_selected().is_none());
}
#[test]
fn confirm_request_overrides_a_prior_one() {
let mut state = ProjectCtlState::default();
state.request_confirm(ConfirmKind::Kill, "sess-1", "one");
state.request_confirm(ConfirmKind::Decommission, "sess-2", "two");
let confirm = state.pending_confirm.expect("confirm requested");
assert_eq!(confirm.kind, ConfirmKind::Decommission);
assert_eq!(confirm.session_id, "sess-2");
}
fn sample_deliverable(id: crate::deliverable::DeliverableId) -> Deliverable {
Deliverable {
id,
project_name: "widget".to_string(),
name: "OAuth2 flow".to_string(),
description: String::new(),
kind: crate::deliverable::DeliverableKind::Feature,
ticket_ref: None,
spec_ref: None,
status: crate::deliverable::DeliverableStatus::InProgress,
estimated_effort: crate::deliverable::EstimationTier::M,
created_at: chrono::Utc::now(),
target_date: None,
}
}
#[test]
fn deliverable_link_state_unknown_when_not_yet_fetched() {
let state = ProjectCtlState::default();
assert!(state.deliverables.is_none());
assert_eq!(
state.deliverable_link_state("any-id"),
DeliverableLinkState::Unknown,
"before any successful fetch (or after one keeps failing), the link state \
must be Unknown, never Dangling"
);
}
#[test]
fn deliverable_link_state_resolved_and_dangling() {
let id = crate::deliverable::DeliverableId::new();
let state = ProjectCtlState {
deliverables: Some(vec![sample_deliverable(id)]),
..Default::default()
};
assert_eq!(
state.deliverable_link_state(&id.to_string()),
DeliverableLinkState::Resolved
);
assert_eq!(
state.deliverable_link_state("00000000-0000-0000-0000-000000000000"),
DeliverableLinkState::Dangling,
"a Some(list) that does not contain the id is a CONFIRMED dangling ref"
);
}
}