use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Gauge, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;
use trusty_common::monitor::tui_common::{panel_block, render_help_overlay, truncate};
use super::state::{RepoSource, TuiState, View, WorkStatus};
pub const KEY_HINT: &str =
"[Tab] view [↑↓] move [Space] pick [a] all [r] pull+correlate [c] correlate \
[f] filter [g] reload [?] help [q] quit";
pub fn help_text() -> String {
"Tab / 1 2 3 switch view Space pick repo\n\
↑ ↓ / j k move cursor a all / none\n\
r pull + correlate selected repos\n\
c correlate only (no network)\n\
f cycle results filter (all/linked/unlinked)\n\
g reload results q / Esc quit\n\
\n\
Every view is deterministic: no model and no API key are required."
.to_string()
}
pub fn title_line(state: &TuiState) -> Line<'static> {
let mut spans = vec![Span::styled(
"tga ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)];
for view in [View::Repos, View::Progress, View::Results] {
let style = if view == state.view {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
spans.push(Span::styled(format!(" {} ", view.label()), style));
spans.push(Span::raw(" "));
}
Line::from(spans)
}
pub fn status_line(state: &TuiState) -> String {
let work = match &state.status {
WorkStatus::Idle => "idle".to_string(),
WorkStatus::Running => "running…".to_string(),
WorkStatus::Finished(summary) => summary.clone(),
};
let dropped = state.progress.dropped();
let gap = if dropped > 0 {
format!(" (dropped {dropped} line(s) under load)")
} else {
String::new()
};
match state.message.as_deref() {
Some(m) => format!("{work}{gap} — {m}"),
None => format!("{work}{gap} — {KEY_HINT}"),
}
}
pub fn repo_line(state: &TuiState, index: usize) -> String {
let Some(entry) = state.repos.get(index) else {
return String::new();
};
let mark = if !entry.is_selectable() {
" "
} else if entry.selected {
"[x]"
} else {
"[ ]"
};
format!(
"{mark} {:<28} {:<7} {}",
truncate(&entry.name, 28),
entry.source.label(),
truncate(&entry.detail, 60)
)
}
pub fn progress_lines(state: &TuiState) -> Vec<String> {
let mut out = Vec::new();
for stage in state.progress.stages() {
let summary = state.progress.summary(stage);
if !summary.is_started() {
continue;
}
out.push(format!(
"{} — {} done, {} failed, {} skipped, {} running",
stage.label(),
summary.completed,
summary.failed,
summary.skipped,
summary.running
));
for row in state.progress.rows(stage) {
let status = match &row.outcome {
Some(o) => o.label().to_string(),
None => "…".to_string(),
};
let counts = match row.total {
Some(t) => format!("{}/{}", row.done, t),
None => row.done.to_string(),
};
let detail = row.detail.as_deref().unwrap_or_default();
out.push(format!(
" {:<32} {:>12} {:<8} {}",
truncate(&row.target, 32),
counts,
status,
truncate(detail, 60)
));
}
}
out
}
pub fn results_header(state: &TuiState) -> String {
let c = &state.counts;
format!(
"{} commits — {} linked ({:.0}%), {} unlinked ({} with a ticket, {} without) \
| {} board items, {} referenced | filter: {}",
c.commits,
c.linked,
c.linked_fraction() * 100.0,
c.unlinked(),
c.ticketed_unlinked,
c.unticketed,
c.work_items,
c.work_items_linked,
state.filter.label()
)
}
pub fn result_line(state: &TuiState, index: usize) -> String {
let Some(row) = state.rows.get(index) else {
return String::new();
};
let sha: String = row.sha.chars().take(8).collect();
let links = if row.work_items.is_empty() {
match row.ticket_id.as_deref() {
Some(t) => format!("— no board item for {t}"),
None => "— no ticket reference".to_string(),
}
} else {
row.work_items
.iter()
.map(|(source, id, title)| format!("{source}:{id} {}", truncate(title, 30)))
.collect::<Vec<_>>()
.join(", ")
};
format!(
"{sha} {:<18} {:<44} {}",
truncate(&row.repository, 18),
truncate(&row.subject, 44),
truncate(&links, 60)
)
}
pub fn render(frame: &mut Frame, state: &TuiState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(2),
])
.split(frame.area());
frame.render_widget(Paragraph::new(title_line(state)), chunks[0]);
match state.view {
View::Repos => render_repos(frame, state, chunks[1]),
View::Progress => render_progress(frame, state, chunks[1]),
View::Results => render_results(frame, state, chunks[1]),
}
frame.render_widget(
Paragraph::new(status_line(state))
.style(Style::default().fg(Color::Gray))
.wrap(Wrap { trim: true }),
chunks[2],
);
if state.show_help {
render_help_overlay(frame, &help_text());
}
}
fn render_repos(frame: &mut Frame, state: &TuiState, area: Rect) {
let items: Vec<ListItem> = (0..state.repos.len())
.map(|i| {
let style = if i == state.repo_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if state.repos[i].source == RepoSource::Org {
Style::default().fg(Color::DarkGray)
} else {
Style::default()
};
ListItem::new(repo_line(state, i)).style(style)
})
.collect();
let selected = state.repos.iter().filter(|r| r.selected).count();
let title = format!("REPOSITORIES ({selected}/{} selected)", state.repos.len());
frame.render_widget(List::new(items).block(panel_block(&title, true)), area);
}
fn render_progress(frame: &mut Frame, state: &TuiState, area: Rect) {
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(area);
if state.progress.is_empty() {
frame.render_widget(
Paragraph::new("Nothing has run yet — press [r] to pull and correlate.")
.block(panel_block("PROGRESS", true)),
split[0],
);
} else {
let lines: Vec<ListItem> = progress_lines(state)
.into_iter()
.map(ListItem::new)
.collect();
frame.render_widget(
List::new(lines).block(panel_block("PROGRESS", true)),
split[0],
);
}
let log: Vec<ListItem> = state
.progress
.log()
.rev()
.take(split[1].height.saturating_sub(2) as usize)
.map(|l| ListItem::new(l.clone()))
.collect();
frame.render_widget(
List::new(log).block(panel_block("ACTIVITY", false)),
split[1],
);
}
fn render_results(frame: &mut Frame, state: &TuiState, area: Rect) {
let split = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(1)])
.split(area);
let ratio = state.counts.linked_fraction().clamp(0.0, 1.0);
frame.render_widget(
Gauge::default()
.block(panel_block("CORRELATION", false))
.gauge_style(Style::default().fg(Color::Green))
.ratio(ratio)
.label(results_header(state)),
split[0],
);
let items: Vec<ListItem> = (0..state.rows.len())
.map(|i| {
let style = if i == state.row_cursor {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if state.rows[i].is_linked() {
Style::default()
} else {
Style::default().fg(Color::Yellow)
};
ListItem::new(result_line(state, i)).style(style)
})
.collect();
let title = format!("COMMITS ({} loaded)", state.rows.len());
frame.render_widget(List::new(items).block(panel_block(&title, true)), split[1]);
}