use crate::tui::app::{App, BranchDisplay};
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
pub fn render_details(f: &mut Frame, app: &App, area: Rect) {
let branch = app.selected_branch();
let content = if let Some(branch) = branch {
build_details_content(branch)
} else {
vec![Line::from("No branch selected")]
};
let paragraph = Paragraph::new(content).block(
Block::default()
.borders(Borders::ALL)
.title(Span::styled(
" Summary ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Color::Cyan)),
);
f.render_widget(paragraph, area);
}
fn build_details_content(branch: &BranchDisplay) -> Vec<Line<'static>> {
let mut lines = Vec::new();
lines.push(Line::from(vec![
Span::styled(
truncate_middle(&branch.name, 32),
Style::default()
.fg(if branch.is_trunk {
Color::Blue
} else {
Color::White
})
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", Style::default()),
badge_span("current", branch.is_current, Color::Green),
badge_span("trunk", branch.is_trunk, Color::Blue),
badge_span("needs restack", branch.needs_restack, Color::Red),
]));
lines.push(Line::from(vec![
Span::styled("Next: ", Style::default().fg(Color::DarkGray)),
primary_action_span(branch),
]));
lines.push(Line::from(vec![
Span::styled("Actions: ", Style::default().fg(Color::DarkGray)),
Span::raw(branch_actions(branch)),
]));
if let Some(parent) = &branch.parent {
lines.push(Line::from(vec![
Span::styled("Base: ", Style::default().fg(Color::DarkGray)),
Span::styled(
truncate_middle(parent, 26),
Style::default().fg(Color::Blue),
),
Span::styled(" • ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}c", branch.commits.len()),
Style::default().fg(Color::White),
),
Span::styled(" • ", Style::default().fg(Color::DarkGray)),
Span::styled(
format!("{}↑", branch.ahead),
Style::default().fg(Color::Green),
),
Span::styled(" ", Style::default()),
Span::styled(
format!("{}↓", branch.behind),
Style::default().fg(Color::Red),
),
]));
}
lines.push(Line::from(vec![
Span::styled("Remote: ", Style::default().fg(Color::DarkGray)),
Span::styled(remote_summary(branch), remote_summary_style(branch)),
Span::styled(" • ", Style::default().fg(Color::DarkGray)),
Span::styled("PR: ", Style::default().fg(Color::DarkGray)),
pr_summary_span(branch),
Span::styled(" • ", Style::default().fg(Color::DarkGray)),
Span::styled("CI: ", Style::default().fg(Color::DarkGray)),
ci_summary_span(branch),
]));
lines.push(Line::from(vec![Span::styled(
"Recent commits",
Style::default().add_modifier(Modifier::BOLD),
)]));
if branch.commits.is_empty() {
lines.push(Line::from(vec![Span::styled(
"• No branch-specific commits yet",
Style::default().fg(Color::DarkGray),
)]));
} else {
for commit in branch.commits.iter().take(3) {
lines.push(Line::from(vec![
Span::styled("• ", Style::default().fg(Color::DarkGray)),
Span::raw(truncate(commit, 72)),
]));
}
if branch.commits.len() > 3 {
lines.push(Line::from(vec![Span::styled(
format!("+{} more commits", branch.commits.len() - 3),
Style::default().fg(Color::DarkGray),
)]));
}
}
lines
}
fn primary_action_span(branch: &BranchDisplay) -> Span<'static> {
let (label, color) = if !branch.is_current {
(
"Press Enter to checkout this branch".to_string(),
Color::Green,
)
} else if branch.is_trunk {
(
"Create a branch with n to start a stack".to_string(),
Color::Cyan,
)
} else if branch.needs_restack {
(
format!(
"Press r to restack onto {}",
truncate_middle(branch.parent.as_deref().unwrap_or("its parent"), 24)
),
Color::Yellow,
)
} else if branch.pr_number.is_none() || !branch.has_remote || branch.unpushed > 0 {
(
"Press s to submit and sync the branch".to_string(),
Color::Green,
)
} else if branch.ci_state.as_deref() == Some("failure")
|| branch.ci_state.as_deref() == Some("error")
{
(
"Press p to inspect the PR and fix CI".to_string(),
Color::Red,
)
} else if branch.pr_number.is_some() {
(
"Press p to open the PR in your browser".to_string(),
Color::Cyan,
)
} else {
("No action needed right now".to_string(), Color::DarkGray)
};
Span::styled(label, Style::default().fg(color))
}
fn branch_actions(branch: &BranchDisplay) -> String {
if !branch.is_current {
if branch.pr_number.is_some() {
"Enter checkout • p open PR".to_string()
} else {
"Enter checkout".to_string()
}
} else if branch.is_trunk {
"n create branch • / search".to_string()
} else {
let mut actions = vec!["s submit".to_string(), "o reorder".to_string()];
if branch.needs_restack {
actions.insert(0, "r restack".to_string());
}
if branch.pr_number.is_some() {
actions.push("p open PR".to_string());
}
actions.push("e rename".to_string());
actions.join(" • ")
}
}
fn remote_summary(branch: &BranchDisplay) -> String {
if branch.is_trunk {
"local".to_string()
} else if !branch.has_remote {
"not pushed".to_string()
} else if branch.unpushed == 0 && branch.unpulled == 0 {
"synced".to_string()
} else if branch.unpushed > 0 && branch.unpulled > 0 {
format!("{}↑ {}↓", branch.unpushed, branch.unpulled)
} else if branch.unpushed > 0 {
format!("{}↑", branch.unpushed)
} else {
format!("{}↓", branch.unpulled)
}
}
fn remote_summary_style(branch: &BranchDisplay) -> Style {
if branch.is_trunk {
Style::default().fg(Color::DarkGray)
} else if !branch.has_remote {
Style::default().fg(Color::Yellow)
} else if branch.unpulled > 0 {
Style::default().fg(Color::Magenta)
} else if branch.unpushed > 0 {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Green)
}
}
fn pr_summary_span(branch: &BranchDisplay) -> Span<'static> {
if let Some(pr_num) = branch.pr_number {
let state = branch
.pr_state
.clone()
.unwrap_or_else(|| "unknown".to_string());
let color = match state.to_lowercase().as_str() {
"open" => Color::Green,
"closed" => Color::Red,
"merged" => Color::Magenta,
_ => Color::Yellow,
};
Span::styled(format!("#{} {}", pr_num, state), Style::default().fg(color))
} else if branch.is_trunk {
Span::styled("n/a", Style::default().fg(Color::DarkGray))
} else {
Span::styled("not created", Style::default().fg(Color::Yellow))
}
}
fn ci_summary_span(branch: &BranchDisplay) -> Span<'static> {
let (label, color) = match branch.ci_state.as_deref() {
Some("success") => ("pass", Color::Green),
Some("failure") | Some("error") => ("fail", Color::Red),
Some("pending") => ("pending", Color::Yellow),
Some(other) => (other, Color::DarkGray),
None => ("?", Color::DarkGray),
};
Span::styled(label.to_string(), Style::default().fg(color))
}
fn badge_span(label: &str, show: bool, color: Color) -> Span<'static> {
if show {
Span::styled(
format!("[{}] ", label),
Style::default().fg(color).add_modifier(Modifier::BOLD),
)
} else {
Span::raw("")
}
}
fn truncate(text: &str, max_len: usize) -> String {
if text.chars().count() <= max_len {
text.to_string()
} else {
let mut truncated = text
.chars()
.take(max_len.saturating_sub(1))
.collect::<String>();
truncated.push('…');
truncated
}
}
fn truncate_middle(text: &str, max_len: usize) -> String {
let chars = text.chars().collect::<Vec<_>>();
if chars.len() <= max_len {
return text.to_string();
}
if max_len <= 1 {
return "…".to_string();
}
let left_len = (max_len - 1) / 2;
let right_len = max_len - 1 - left_len;
let left = chars.iter().take(left_len).collect::<String>();
let right = chars
.iter()
.skip(chars.len().saturating_sub(right_len))
.collect::<String>();
format!("{}…{}", left, right)
}
#[cfg(test)]
mod tests {
use super::{branch_actions, primary_action_span, remote_summary, truncate, truncate_middle};
use crate::tui::app::BranchDisplay;
fn branch() -> BranchDisplay {
BranchDisplay {
name: "feature/demo".to_string(),
parent: Some("main".to_string()),
column: 0,
is_current: true,
is_trunk: false,
ahead: 2,
behind: 0,
needs_restack: false,
has_remote: false,
unpushed: 0,
unpulled: 0,
pr_number: None,
pr_state: None,
ci_state: None,
commits: vec!["add thing".to_string()],
}
}
#[test]
fn suggests_checkout_for_non_current_branch() {
let mut branch = branch();
branch.is_current = false;
assert_eq!(
primary_action_span(&branch).content,
"Press Enter to checkout this branch"
);
}
#[test]
fn suggests_submit_for_unpublished_branch() {
let branch = branch();
assert_eq!(
primary_action_span(&branch).content,
"Press s to submit and sync the branch"
);
}
#[test]
fn branch_actions_prioritize_restack_when_needed() {
let mut branch = branch();
branch.needs_restack = true;
assert!(branch_actions(&branch).starts_with("r restack"));
}
#[test]
fn remote_summary_reports_bidirectional_drift() {
let mut branch = branch();
branch.has_remote = true;
branch.unpushed = 2;
branch.unpulled = 1;
assert_eq!(remote_summary(&branch), "2↑ 1↓");
}
#[test]
fn truncate_adds_ellipsis() {
assert_eq!(truncate("abcdefghijklmnopqrstuvwxyz", 10), "abcdefghi…");
}
#[test]
fn truncate_middle_preserves_prefix_and_suffix() {
assert_eq!(
truncate_middle("feature/super-long-branch-name/demo", 12),
"featu…e/demo"
);
}
}