use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, HighlightSpacing, List, ListItem},
};
use crate::tui::project_ctl::state::{DeliverableLinkState, Pane, ProjectCtlState, SessionRow};
pub const ACTIVE_GLYPH: char = '●';
pub const PROVISIONING_GLYPH: char = '◍';
pub const STOPPED_GLYPH: char = '○';
pub const ERRORED_GLYPH: char = '✗';
pub const DECOMMISSIONED_GLYPH: char = '⊘';
pub fn state_glyph(state: &str) -> char {
match state {
"active" => ACTIVE_GLYPH,
"provisioning" => PROVISIONING_GLYPH,
"errored" => ERRORED_GLYPH,
"decommissioned" => DECOMMISSIONED_GLYPH,
_ => STOPPED_GLYPH,
}
}
pub const DELIVERABLE_GLYPH: char = '◆';
pub const DELIVERABLE_DANGLING_GLYPH: char = '◈';
pub fn deliverable_glyph(
deliverable_id: Option<&str>,
link_state: DeliverableLinkState,
) -> Option<(char, Style)> {
deliverable_id?;
match link_state {
DeliverableLinkState::Unknown => None,
DeliverableLinkState::Resolved => {
Some((DELIVERABLE_GLYPH, Style::default().fg(Color::Cyan)))
}
DeliverableLinkState::Dangling => Some((
DELIVERABLE_DANGLING_GLYPH,
Style::default().fg(Color::Red).add_modifier(Modifier::DIM),
)),
}
}
pub fn session_line(
number: usize,
row: &SessionRow,
deliverable_link_state: DeliverableLinkState,
) -> Line<'static> {
let glyph = state_glyph(&row.state);
let branch = row.branch.clone().unwrap_or_else(|| "-".to_string());
let detail = row.task.clone().unwrap_or_else(|| row.state.clone());
let mut spans = vec![
Span::styled(
format!("{number:>2}. "),
Style::default().fg(Color::DarkGray),
),
Span::styled(format!("{glyph} "), glyph_style(&row.state)),
Span::styled(
format!("{} ", row.short_id),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!("{branch} ")),
Span::styled(detail, Style::default().fg(Color::DarkGray)),
];
if let Some((glyph, style)) =
deliverable_glyph(row.deliverable_id.as_deref(), deliverable_link_state)
{
spans.push(Span::styled(format!(" {glyph}"), style));
}
Line::from(spans)
}
fn glyph_style(state: &str) -> Style {
match state {
"active" => Style::default().fg(Color::Green),
"provisioning" => Style::default().fg(Color::Yellow),
"errored" => Style::default().fg(Color::Red),
"decommissioned" => Style::default().fg(Color::DarkGray),
_ => Style::default().fg(Color::Gray),
}
}
pub fn render(frame: &mut Frame, area: Rect, state: &mut ProjectCtlState) {
let focused = state.focus == Pane::Sessions;
let sessions = state.current_sessions().to_vec();
let items: Vec<ListItem> = sessions
.iter()
.enumerate()
.map(|(i, s)| {
let link_state = s
.deliverable_id
.as_deref()
.map(|id| state.deliverable_link_state(id))
.unwrap_or(DeliverableLinkState::Unknown);
ListItem::new(session_line(i + 1, s, link_state))
})
.collect();
let project_label = state.selected_project_name().unwrap_or("-");
let title = Line::from(format!("SESSIONS — {project_label} ({})", sessions.len()))
.style(title_style(focused));
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(
Style::default()
.fg(Color::White)
.bg(Color::Blue)
.add_modifier(Modifier::BOLD),
)
.highlight_spacing(HighlightSpacing::Always);
frame.render_stateful_widget(list, area, state.sessions_nav.state_mut());
}
fn title_style(focused: bool) -> Style {
if focused {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::BOLD)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(state: &str) -> SessionRow {
SessionRow {
id: "4f9ca1b2ffff".to_string(),
short_id: "4f9ca1b2".to_string(),
name: "session".to_string(),
branch: Some("feat/x".to_string()),
task: Some("ship the thing".to_string()),
state: state.to_string(),
pending_decision: None,
proposed_default: None,
deliverable_id: None,
unresumable: false,
}
}
fn line_text(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn state_glyph_maps_every_known_state() {
assert_eq!(state_glyph("active"), ACTIVE_GLYPH);
assert_eq!(state_glyph("provisioning"), PROVISIONING_GLYPH);
assert_eq!(state_glyph("stopped"), STOPPED_GLYPH);
assert_eq!(state_glyph("errored"), ERRORED_GLYPH);
assert_eq!(state_glyph("decommissioned"), DECOMMISSIONED_GLYPH);
}
#[test]
fn state_glyph_unknown_falls_back() {
assert_eq!(state_glyph("something-new"), STOPPED_GLYPH);
}
#[test]
fn session_line_shows_number_glyph_branch_and_task() {
let text = line_text(&session_line(
1,
&row("active"),
DeliverableLinkState::Unknown,
));
assert!(text.starts_with(" 1. "), "missing number: {text}");
assert!(text.contains(ACTIVE_GLYPH), "missing glyph: {text}");
assert!(text.contains("4f9ca1b2"), "missing short id: {text}");
assert!(text.contains("feat/x"), "missing branch: {text}");
assert!(text.contains("ship the thing"), "missing task: {text}");
}
#[test]
fn session_line_falls_back_to_state_word() {
let mut r = row("stopped");
r.task = None;
let text = line_text(&session_line(2, &r, DeliverableLinkState::Unknown));
assert!(text.contains("stopped"), "missing state fallback: {text}");
}
#[test]
fn deliverable_glyph_none_when_unbound() {
assert_eq!(
deliverable_glyph(None, DeliverableLinkState::Resolved),
None
);
assert_eq!(
deliverable_glyph(None, DeliverableLinkState::Dangling),
None
);
assert_eq!(deliverable_glyph(None, DeliverableLinkState::Unknown), None);
}
#[test]
fn deliverable_glyph_none_when_unknown() {
assert_eq!(
deliverable_glyph(Some("d-1"), DeliverableLinkState::Unknown),
None
);
}
#[test]
fn deliverable_glyph_resolved_and_dangling() {
let (glyph, style) = deliverable_glyph(Some("d-1"), DeliverableLinkState::Resolved)
.expect("resolved must show a glyph");
assert_eq!(glyph, DELIVERABLE_GLYPH);
assert_eq!(style.fg, Some(Color::Cyan));
let (glyph, style) = deliverable_glyph(Some("d-1"), DeliverableLinkState::Dangling)
.expect("dangling must show a glyph");
assert_eq!(glyph, DELIVERABLE_DANGLING_GLYPH);
assert_eq!(style.fg, Some(Color::Red));
assert!(
style.add_modifier.contains(Modifier::DIM),
"the dangling glyph must be dimmed so it reads distinct from the \
full-intensity red ERRORED_GLYPH: {style:?}"
);
}
#[test]
fn session_line_appends_deliverable_glyph_when_bound() {
let mut r = row("active");
r.deliverable_id = None;
assert!(
!line_text(&session_line(1, &r, DeliverableLinkState::Resolved))
.contains(DELIVERABLE_GLYPH),
"unbound row must show no deliverable glyph"
);
r.deliverable_id = Some("d-1".to_string());
let unknown_text = line_text(&session_line(1, &r, DeliverableLinkState::Unknown));
assert!(
!unknown_text.contains(DELIVERABLE_GLYPH)
&& !unknown_text.contains(DELIVERABLE_DANGLING_GLYPH),
"a bound row with an Unknown link state must show NO deliverable glyph: {unknown_text}"
);
let resolved_text = line_text(&session_line(1, &r, DeliverableLinkState::Resolved));
assert!(
resolved_text.contains(DELIVERABLE_GLYPH),
"resolved link must show the resolved glyph: {resolved_text}"
);
let dangling_text = line_text(&session_line(1, &r, DeliverableLinkState::Dangling));
assert!(
dangling_text.contains(DELIVERABLE_DANGLING_GLYPH),
"dangling link must show the dangling glyph: {dangling_text}"
);
}
}