use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
use super::super::helpers::{
centered_overlay_rect, compute_overlay_size_standard, display_width, fit_lines_to_inner_area,
truncate_to_width_with_ellipsis, BRANCH_MARKER_SELECTED, BRANCH_MARKER_UNSELECTED,
BRANCH_OVERLAY_MIN_WIDTH, BRANCH_OVERLAY_PADDING, CURRENT_BRANCH_MARKER,
NOT_CURRENT_BRANCH_MARKER, OVERLAY_BG_COLOR, OVERLAY_BORDER_HEIGHT, OVERLAY_MARGIN,
OVERLAY_MIN_HEIGHT, OVERLAY_MIN_WIDTH, STATUS_MIN_PATH_LEN, STATUS_OVERLAY_PADDING,
};
use super::super::theme;
use crate::app::{App, CommitType, QuickAction};
use crate::git::FileStatusKind;
use crate::split::suggest_splits;
use crate::topology::{BranchStatus, HealthWarning, RecommendedAction};
pub(crate) fn render_branch_select_overlay(frame: &mut Frame, app: &App) {
let area = frame.area();
let prefix_width =
(display_width(BRANCH_MARKER_SELECTED) + display_width(CURRENT_BRANCH_MARKER)) as u16;
let max_branch_width = app
.branches
.iter()
.map(|b| display_width(&b.name))
.max()
.unwrap_or(BRANCH_OVERLAY_MIN_WIDTH as usize)
.max(BRANCH_OVERLAY_MIN_WIDTH as usize) as u16;
let overlay_width = (max_branch_width + prefix_width + BRANCH_OVERLAY_PADDING)
.min(area.width.saturating_sub(OVERLAY_MARGIN));
let overlay_height = (app.branches.len() as u16 + OVERLAY_BORDER_HEIGHT)
.min(area.height.saturating_sub(OVERLAY_MARGIN))
.max(OVERLAY_MIN_HEIGHT);
let overlay_x = (area.width.saturating_sub(overlay_width)) / 2;
let overlay_y = (area.height.saturating_sub(overlay_height)) / 2;
let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
frame.render_widget(Clear, overlay_area);
let items: Vec<ListItem> = app
.branches
.iter()
.enumerate()
.map(|(i, branch_info)| {
let is_selected = i == app.branch_selected_index;
let is_current = app.branch_name() == branch_info.name;
let select_marker = if is_selected {
BRANCH_MARKER_SELECTED
} else {
BRANCH_MARKER_UNSELECTED
};
let current_marker = if is_current {
CURRENT_BRANCH_MARKER
} else {
NOT_CURRENT_BRANCH_MARKER
};
let select_style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let current_marker_style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::GREEN)
};
let branch_style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else if branch_info.is_gone {
Style::default().fg(theme::SUBTEXT0)
} else if is_current {
Style::default().fg(theme::GREEN)
} else {
Style::default()
};
let branch_label = if branch_info.is_gone {
format!("{} [gone]", branch_info.name)
} else {
branch_info.name.clone()
};
ListItem::new(Line::from(vec![
Span::styled(select_marker, select_style),
Span::styled(current_marker, current_marker_style),
Span::styled(branch_label, branch_style),
]))
})
.collect();
let list = List::new(items).block(
Block::default()
.title(app.language.select_branch())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
let mut list_state = ratatui::widgets::ListState::default();
list_state.select(Some(app.branch_selected_index));
frame.render_stateful_widget(list, overlay_area, &mut list_state);
}
pub(crate) fn render_status_view_overlay(frame: &mut Frame, app: &App) {
let area = frame.area();
let max_path_width = app
.file_statuses
.iter()
.map(|s| display_width(&s.path))
.max()
.unwrap_or(STATUS_MIN_PATH_LEN)
.max(STATUS_MIN_PATH_LEN) as u16;
let overlay_width =
(max_path_width + STATUS_OVERLAY_PADDING).min(area.width.saturating_sub(OVERLAY_MARGIN));
let overlay_height = (app.file_statuses.len() as u16 + OVERLAY_BORDER_HEIGHT)
.min(area.height.saturating_sub(OVERLAY_MARGIN))
.max(OVERLAY_MIN_HEIGHT);
let overlay_x = (area.width.saturating_sub(overlay_width)) / 2;
let overlay_y = (area.height.saturating_sub(overlay_height)) / 2;
let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
frame.render_widget(Clear, overlay_area);
if app.file_statuses.is_empty() {
let content = Paragraph::new(Line::from(app.language.no_changes())).block(
Block::default()
.title(app.language.git_status())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(content, overlay_area);
return;
}
let split_suggestion = suggest_splits(&app.file_statuses, None);
let split_lines: usize = if let Some(ref suggestion) = split_suggestion {
2 + suggestion.groups.len() * 2
} else {
0
};
let overlay_height =
(app.file_statuses.len() as u16 + split_lines as u16 + OVERLAY_BORDER_HEIGHT)
.min(area.height.saturating_sub(OVERLAY_MARGIN))
.max(OVERLAY_MIN_HEIGHT);
let overlay_y = (area.height.saturating_sub(overlay_height)) / 2;
let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
frame.render_widget(Clear, overlay_area);
let mut lines: Vec<Line> = Vec::new();
for (i, status) in app.file_statuses.iter().enumerate() {
let is_selected = i == app.status_selected_index;
let (prefix, color) = match status.kind {
FileStatusKind::StagedNew => ("A ", theme::GREEN),
FileStatusKind::StagedModified => ("M ", theme::GREEN),
FileStatusKind::StagedDeleted => ("D ", theme::GREEN),
FileStatusKind::Modified => ("M ", theme::RED),
FileStatusKind::Deleted => ("D ", theme::RED),
FileStatusKind::Untracked => ("? ", theme::OVERLAY0),
};
let style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(color)
};
lines.push(Line::from(Span::styled(
format!("{}{}", prefix, status.path),
style,
)));
}
if let Some(ref suggestion) = split_suggestion {
let sep = "\u{2500}".repeat(overlay_width.saturating_sub(4) as usize);
lines.push(Line::from(Span::styled(
sep,
Style::default().fg(theme::SEPARATOR),
)));
lines.push(Line::from(Span::styled(
"PR Split Suggestion",
Style::default()
.fg(theme::LAVENDER)
.add_modifier(Modifier::BOLD),
)));
for group in &suggestion.groups {
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", group.suggested_title),
Style::default().fg(theme::SAPPHIRE),
),
Span::styled(
format!("({} files)", group.files.len()),
Style::default().fg(theme::SUBTEXT0),
),
]));
lines.push(Line::from(Span::styled(
format!(" {}", group.reason),
Style::default().fg(theme::OVERLAY0),
)));
}
}
let paragraph = Paragraph::new(lines).block(
Block::default()
.title(app.language.git_status())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_quick_commit_overlay(frame: &mut Frame, app: &App) {
let area = frame.area();
let has_suggestions = !app.commit_suggestions.is_empty();
let base_height = if has_suggestions { 14u16 } else { 9u16 };
let overlay_width = 56u16.min(area.width.saturating_sub(OVERLAY_MARGIN));
let overlay_height = base_height.min(area.height.saturating_sub(OVERLAY_MARGIN));
let overlay_x = (area.width.saturating_sub(overlay_width)) / 2;
let overlay_y = (area.height.saturating_sub(overlay_height)) / 2;
let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
frame.render_widget(Clear, overlay_area);
let key_style = Style::default().fg(theme::SAPPHIRE);
let type_style = Style::default().fg(theme::TEXT);
let selected_style = Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD);
let dim_style = Style::default().fg(theme::SUBTEXT0);
let types_row1 = CommitType::all()[..4].iter();
let types_row2 = CommitType::all()[4..].iter();
let make_type_span = |ct: &CommitType| -> Vec<Span<'static>> {
let is_selected = app.commit_type == Some(*ct);
let style = if is_selected {
selected_style
} else {
type_style
};
vec![
Span::styled(format!("[{}] ", ct.key()), key_style),
Span::styled(format!("{:<8}", ct.name()), style),
]
};
let mut row1_spans: Vec<Span<'static>> = vec![Span::raw(" ")];
for ct in types_row1 {
row1_spans.extend(make_type_span(ct));
row1_spans.push(Span::raw(" "));
}
let mut row2_spans: Vec<Span<'static>> = vec![Span::raw(" ")];
for ct in types_row2 {
row2_spans.extend(make_type_span(ct));
row2_spans.push(Span::raw(" "));
}
let mut content = vec![
Line::from(""),
Line::from(row1_spans),
Line::from(row2_spans),
];
if has_suggestions {
content.push(Line::from(""));
content.push(Line::from(Span::styled(
app.language.commit_suggestions(),
dim_style,
)));
for (i, suggestion) in app.commit_suggestions.iter().enumerate().take(3) {
let is_selected = i == app.suggestion_selected_index;
let style = if is_selected {
selected_style
} else {
type_style
};
let max_msg_width = (overlay_width as usize).saturating_sub(10);
let msg = truncate_to_width_with_ellipsis(&suggestion.full_message(), max_msg_width);
content.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("[{}] ", i + 1), key_style),
Span::styled(msg, style),
]));
}
}
let max_display_width = 30;
let message_display = if app.commit_message.is_empty() {
"_".repeat(max_display_width)
} else {
let msg = &app.commit_message;
let msg_width = display_width(msg);
if msg_width > max_display_width {
truncate_to_width_with_ellipsis(msg, max_display_width)
} else {
format!("{}{}", msg, "_".repeat(max_display_width - msg_width))
}
};
content.push(Line::from(""));
content.push(Line::from(vec![
Span::raw(app.language.commit_message_label()),
Span::styled(message_display, Style::default().fg(theme::YELLOW)),
]));
content.push(Line::from(""));
let footer_text = if has_suggestions {
app.language.commit_footer_with_suggestions()
} else {
app.language.commit_footer()
};
content.push(Line::from(vec![
Span::raw(" "),
Span::styled(footer_text, dim_style),
]));
let paragraph = Paragraph::new(content).block(
Block::default()
.title(app.language.quick_commit())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_help_overlay(frame: &mut Frame, app: &App) {
let area = frame.area();
let lang = app.language;
let desired_width = 50u16
.min(area.width.saturating_sub(OVERLAY_MARGIN))
.max(OVERLAY_MIN_WIDTH);
let desired_height = 40u16.min(area.height.saturating_sub(OVERLAY_MARGIN));
let overlay_area = centered_overlay_rect(area, desired_width, desired_height);
if overlay_area.width < 3 || overlay_area.height < 3 {
return;
}
let inner_width = overlay_area.width.saturating_sub(2) as usize;
let inner_height = overlay_area.height.saturating_sub(2) as usize;
frame.render_widget(Clear, overlay_area);
let label_style = Style::default().fg(theme::YELLOW);
let key_style = Style::default().fg(theme::SAPPHIRE);
let desc_style = Style::default().fg(theme::TEXT);
let content = if inner_width <= 18 {
vec![
Line::from(Span::styled(lang.help_navigation(), label_style)),
Line::from(Span::styled(format!("j/k {}", lang.help_move()), key_style)),
Line::from(Span::styled(format!("/ {}", lang.help_filter()), key_style)),
Line::from(Span::styled("? q", key_style)),
]
} else if inner_width < 25 {
vec![
Line::from(Span::styled(lang.help_nav_short(), label_style)),
Line::from(vec![Span::styled("j/k ↑↓", key_style)]),
Line::from(vec![Span::styled("g/G ^d/^u", key_style)]),
Line::from(""),
Line::from(Span::styled(lang.help_act_short(), label_style)),
Line::from(vec![Span::styled("Enter /", key_style)]),
Line::from(vec![Span::styled("b s z t", key_style)]),
Line::from(""),
Line::from(Span::styled(lang.help_etc_short(), label_style)),
Line::from(vec![Span::styled("? q", key_style)]),
]
} else if inner_width < 30 {
vec![
Line::from(Span::styled(lang.help_navigation(), label_style)),
Line::from(vec![
Span::styled(" j/k ", key_style),
Span::styled(lang.help_move(), desc_style),
]),
Line::from(vec![
Span::styled(" g/G ", key_style),
Span::styled(lang.help_top_bottom(), desc_style),
]),
Line::from(vec![
Span::styled(" ^d/u", key_style),
Span::styled("page", desc_style),
]),
Line::from(""),
Line::from(Span::styled(lang.help_actions(), label_style)),
Line::from(vec![
Span::styled(" Enter", key_style),
Span::styled(lang.help_detail(), desc_style),
]),
Line::from(vec![
Span::styled(" / ", key_style),
Span::styled(lang.help_filter(), desc_style),
]),
Line::from(vec![
Span::styled(" b/s ", key_style),
Span::styled(lang.help_branch_status(), desc_style),
]),
Line::from(vec![
Span::styled(" v/S ", key_style),
Span::styled(lang.help_view_summary(), desc_style),
]),
Line::from(vec![
Span::styled(" ?/q ", key_style),
Span::styled(lang.help_help_quit(), desc_style),
]),
]
} else if inner_width < 40 {
vec![
Line::from(Span::styled(lang.help_navigation(), label_style)),
Line::from(vec![
Span::styled(" j/k ", key_style),
Span::styled(lang.help_up_down(), desc_style),
]),
Line::from(vec![
Span::styled(" g/G ", key_style),
Span::styled(lang.help_top_bottom_full(), desc_style),
]),
Line::from(vec![
Span::styled(" ^d/u", key_style),
Span::styled(lang.help_page_dn_up(), desc_style),
]),
Line::from(vec![
Span::styled(" ]/[ ", key_style),
Span::styled(lang.help_next_prev_branch(), desc_style),
]),
Line::from(vec![
Span::styled(" @ ", key_style),
Span::styled("HEAD", desc_style),
]),
Line::from(""),
Line::from(Span::styled(lang.help_actions(), label_style)),
Line::from(vec![
Span::styled(" Enter", key_style),
Span::styled(format!(" {}", lang.help_detail()), desc_style),
]),
Line::from(vec![
Span::styled(" / ", key_style),
Span::styled(lang.help_filter(), desc_style),
]),
Line::from(vec![
Span::styled(" b ", key_style),
Span::styled(lang.help_branch(), desc_style),
]),
Line::from(vec![
Span::styled(" s ", key_style),
Span::styled(lang.help_status(), desc_style),
]),
Line::from(vec![
Span::styled(" z/Z ", key_style),
Span::styled(lang.help_stash_save_short(), desc_style),
]),
Line::from(""),
Line::from(Span::styled(lang.help_other(), label_style)),
Line::from(vec![
Span::styled(" r ", key_style),
Span::styled(lang.help_fetch(), desc_style),
]),
Line::from(vec![
Span::styled(" t ", key_style),
Span::styled(lang.help_topology(), desc_style),
]),
Line::from(vec![
Span::styled(" v/S ", key_style),
Span::styled(lang.help_view_summary(), desc_style),
]),
Line::from(vec![
Span::styled(" W/P/R", key_style),
Span::styled(lang.help_watch_mode(), desc_style),
]),
Line::from(vec![
Span::styled(" ?/q ", key_style),
Span::styled(lang.help_help_quit(), desc_style),
]),
]
} else {
vec![
Line::from(Span::styled(lang.help_navigation(), label_style)),
Line::from(vec![
Span::styled(" j/↓ ", key_style),
Span::styled(lang.help_move_down(), desc_style),
]),
Line::from(vec![
Span::styled(" k/↑ ", key_style),
Span::styled(lang.help_move_up(), desc_style),
]),
Line::from(vec![
Span::styled(" g ", key_style),
Span::styled(lang.help_go_top(), desc_style),
]),
Line::from(vec![
Span::styled(" G ", key_style),
Span::styled(lang.help_go_bottom(), desc_style),
]),
Line::from(vec![
Span::styled(" ^d ", key_style),
Span::styled(lang.help_page_down(), desc_style),
]),
Line::from(vec![
Span::styled(" ^u ", key_style),
Span::styled(lang.help_page_up(), desc_style),
]),
Line::from(vec![
Span::styled(" ] ", key_style),
Span::styled(lang.help_next_branch(), desc_style),
]),
Line::from(vec![
Span::styled(" [ ", key_style),
Span::styled(lang.help_prev_branch(), desc_style),
]),
Line::from(vec![
Span::styled(" @ ", key_style),
Span::styled("HEAD", desc_style),
]),
Line::from(""),
Line::from(Span::styled(lang.help_actions(), label_style)),
Line::from(vec![
Span::styled(" Enter", key_style),
Span::styled(format!(" {}", lang.help_show_detail()), desc_style),
]),
Line::from(vec![
Span::styled(" / ", key_style),
Span::styled(lang.help_filter(), desc_style),
]),
Line::from(vec![
Span::styled(" b ", key_style),
Span::styled(lang.help_branch_select(), desc_style),
]),
Line::from(vec![
Span::styled(" s ", key_style),
Span::styled(lang.help_status_view(), desc_style),
]),
Line::from(vec![
Span::styled(" z ", key_style),
Span::styled(lang.help_stash_list(), desc_style),
]),
Line::from(vec![
Span::styled(" Z ", key_style),
Span::styled(lang.help_stash_save(), desc_style),
]),
Line::from(""),
Line::from(Span::styled(lang.help_dashboard(), label_style)),
Line::from(vec![
Span::styled(" 1-5 ", key_style),
Span::styled(lang.help_switch_panel(), desc_style),
]),
Line::from(vec![
Span::styled(" Tab ", key_style),
Span::styled(lang.help_toggle_focus(), desc_style),
]),
Line::from(vec![
Span::styled(" h/l ", key_style),
Span::styled(lang.help_focus_side_main(), desc_style),
]),
Line::from(vec![
Span::styled(" ←/→ ", key_style),
Span::styled(lang.help_scroll_diff_h(), desc_style),
]),
Line::from(""),
Line::from(Span::styled(lang.help_other(), label_style)),
Line::from(vec![
Span::styled(" r ", key_style),
Span::styled(lang.help_fetch_remote(), desc_style),
]),
Line::from(vec![
Span::styled(" v ", key_style),
Span::styled(lang.help_cycle_view(), desc_style),
]),
Line::from(vec![
Span::styled(" S ", key_style),
Span::styled(lang.help_toggle_summary(), desc_style),
]),
Line::from(vec![
Span::styled(" t ", key_style),
Span::styled(lang.help_branch_topology(), desc_style),
]),
Line::from(vec![
Span::styled(" W ", key_style),
Span::styled(lang.help_watch_mode(), desc_style),
]),
Line::from(vec![
Span::styled(" P ", key_style),
Span::styled(lang.help_pr_create(), desc_style),
]),
Line::from(vec![
Span::styled(" R ", key_style),
Span::styled(lang.help_review_queue(), desc_style),
]),
Line::from(vec![
Span::styled(" ? ", key_style),
Span::styled(lang.help_toggle_help(), desc_style),
]),
Line::from(vec![
Span::styled(" q ", key_style),
Span::styled(lang.help_quit(), desc_style),
]),
]
};
let content = fit_lines_to_inner_area(content, inner_width, inner_height);
let title = if inner_width < 20 {
lang.help()
} else {
lang.help_adaptive()
};
let paragraph = Paragraph::new(content).block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_topology_view_overlay(frame: &mut Frame, app: &App) {
let area = frame.area();
let overlay_width = ((area.width as f32 * 0.8) as u16)
.max(OVERLAY_MIN_WIDTH)
.min(area.width.saturating_sub(OVERLAY_MARGIN * 2));
let overlay_height = (area.height as f32 * 0.8) as u16;
let x = (area.width.saturating_sub(overlay_width)) / 2;
let y = (area.height.saturating_sub(overlay_height)) / 2;
let overlay_area = Rect::new(x, y, overlay_width, overlay_height);
let inner_width = overlay_width.saturating_sub(2) as usize;
frame.render_widget(Clear, overlay_area);
let Some(ref topology) = app.topology_cache else {
let paragraph = Paragraph::new(app.language.loading_topology()).block(
Block::default()
.title(app.language.branch_topology())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
return;
};
let show_health_summary = inner_width >= 50;
let show_age = inner_width >= 40;
let show_relation = inner_width >= 35;
let tree_indent = if inner_width >= 35 { " " } else { " " };
let node_symbol_short = if inner_width >= 35 {
"├─●"
} else {
"├●"
};
let visible_lines = overlay_height.saturating_sub(6) as usize;
let mut content = Vec::new();
let unhealthy = topology.unhealthy_count();
let lang = app.language;
let health_indicator = if inner_width >= 40 {
if unhealthy > 0 {
format!(" ⚠ {}{}", unhealthy, lang.topology_issues())
} else {
lang.topology_healthy().to_string()
}
} else if unhealthy > 0 {
format!(" ⚠{}", unhealthy)
} else {
" ✓".to_string()
};
let health_color = if unhealthy > 0 {
theme::YELLOW
} else {
theme::GREEN
};
if inner_width >= 45 {
content.push(Line::from(vec![
Span::styled(
format!("Main: {} ", topology.main_branch),
Style::default()
.fg(theme::GREEN)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({} branches)", topology.branch_count()),
Style::default().fg(theme::SUBTEXT0),
),
Span::styled(health_indicator, Style::default().fg(health_color)),
]));
} else {
content.push(Line::from(vec![
Span::styled(
format!(
"{} ",
truncate_to_width_with_ellipsis(&topology.main_branch, 10)
),
Style::default()
.fg(theme::GREEN)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", topology.branch_count()),
Style::default().fg(theme::SUBTEXT0),
),
Span::styled(health_indicator, Style::default().fg(health_color)),
]));
}
content.push(Line::from(""));
let scroll_offset = app.topology_nav.scroll_offset;
let end_idx = (scroll_offset + visible_lines).min(topology.branches.len());
let branch_name_width = if inner_width >= 50 {
20
} else if inner_width >= 35 {
inner_width.saturating_sub(20)
} else {
inner_width.saturating_sub(12)
};
for (idx, branch) in topology
.branches
.iter()
.enumerate()
.skip(scroll_offset)
.take(end_idx - scroll_offset)
{
let is_selected = idx == app.topology_nav.selected_index;
let node_symbol = if branch.name == topology.main_branch {
"●"
} else {
node_symbol_short
};
let (color, label) = match branch.status {
BranchStatus::Active => (theme::GREEN, if inner_width >= 40 { "[HEAD]" } else { "*" }),
BranchStatus::Merged => (
theme::BLUE,
if inner_width >= 40 { "[merged]" } else { "M" },
),
BranchStatus::Stale => (theme::RED, if inner_width >= 40 { "[stale]" } else { "S" }),
BranchStatus::Wip => (theme::YELLOW, if inner_width >= 40 { "[wip]" } else { "W" }),
BranchStatus::Normal => (theme::TEXT, ""),
};
let relation_info = if show_relation {
match &branch.relation {
Some(r) if !r.is_merged && (r.ahead_count > 0 || r.behind_count > 0) => {
if inner_width >= 50 {
format!(" ({})", r.summary())
} else {
format!(" +{}-{}", r.ahead_count, r.behind_count)
}
}
_ => String::new(),
}
} else {
String::new()
};
let age_info = if show_age {
let days = chrono::Local::now()
.signed_duration_since(branch.last_activity)
.num_days();
if days > 0 {
format!(" {}d", days)
} else {
String::new()
}
} else {
String::new()
};
let health_icons = branch.health.warning_icons();
let health_display = if health_icons.is_empty() {
String::new()
} else {
format!(" {}", health_icons)
};
let (rec_icon, rec_color) =
if branch.name != topology.main_branch && branch.status != BranchStatus::Active {
if let Some(ref recommendations) = app.branch_recommendations_cache {
if let Some(rec) = recommendations.get_recommendation(&branch.name) {
if rec.action != RecommendedAction::Keep {
let color = match rec.action {
RecommendedAction::Delete => theme::RED,
RecommendedAction::Rebase => theme::YELLOW,
RecommendedAction::Merge => theme::GREEN,
RecommendedAction::Review => theme::SAPPHIRE,
RecommendedAction::Keep => theme::SUBTEXT0,
};
(format!(" {}", rec.action.icon()), color)
} else {
(String::new(), theme::SUBTEXT0)
}
} else {
(String::new(), theme::SUBTEXT0)
}
} else {
(String::new(), theme::SUBTEXT0)
}
} else {
(String::new(), theme::SUBTEXT0)
};
let line_style = if is_selected {
Style::default().bg(theme::SURFACE2).fg(color)
} else {
Style::default().fg(color)
};
let indent = if branch.name == topology.main_branch {
""
} else {
tree_indent
};
let branch_name = truncate_to_width_with_ellipsis(&branch.name, branch_name_width);
let line = Line::from(vec![
Span::styled(format!("{}{} ", indent, node_symbol), line_style),
Span::styled(
format!("{} ", branch_name),
line_style.add_modifier(Modifier::BOLD),
),
Span::styled(label, line_style),
Span::styled(relation_info, line_style),
Span::styled(age_info, Style::default().fg(theme::SUBTEXT0)),
Span::styled(health_display, Style::default().fg(theme::YELLOW)),
Span::styled(rec_icon, Style::default().fg(rec_color)),
]);
content.push(line);
}
content.push(Line::from(""));
if show_health_summary {
let stale_count = topology.warning_count(HealthWarning::Stale);
let long_lived_count = topology.warning_count(HealthWarning::LongLived);
let far_behind_count = topology.warning_count(HealthWarning::FarBehind);
let divergence_count = topology.warning_count(HealthWarning::LargeDivergence);
let mut health_parts = Vec::new();
if stale_count > 0 {
health_parts.push(format!("⚠{}{}", stale_count, lang.topology_stale()));
}
if long_lived_count > 0 {
health_parts.push(format!("⏳{}{}", long_lived_count, lang.topology_long()));
}
if far_behind_count > 0 {
health_parts.push(format!("⬇{}{}", far_behind_count, lang.topology_behind()));
}
if divergence_count > 0 {
health_parts.push(format!("⚡{}{}", divergence_count, lang.topology_div()));
}
if !health_parts.is_empty() {
content.push(Line::from(Span::styled(
health_parts.join(" "),
Style::default().fg(theme::YELLOW),
)));
}
if let Some(ref recommendations) = app.branch_recommendations_cache {
let mut rec_parts = Vec::new();
if recommendations.delete_count > 0 {
rec_parts.push(Span::styled(
format!("🗑{} ", recommendations.delete_count),
Style::default().fg(theme::RED),
));
}
if recommendations.rebase_count > 0 {
rec_parts.push(Span::styled(
format!("↻{} ", recommendations.rebase_count),
Style::default().fg(theme::YELLOW),
));
}
if recommendations.merge_count > 0 {
rec_parts.push(Span::styled(
format!("⤵{} ", recommendations.merge_count),
Style::default().fg(theme::GREEN),
));
}
if recommendations.review_count > 0 {
rec_parts.push(Span::styled(
format!("👁{} ", recommendations.review_count),
Style::default().fg(theme::SAPPHIRE),
));
}
if !rec_parts.is_empty() {
content.push(Line::from(rec_parts));
}
}
}
let scroll_indicator = if topology.branches.len() > visible_lines {
format!(
" [{}/{}]",
app.topology_nav.selected_index + 1,
topology.branches.len()
)
} else {
String::new()
};
if inner_width >= 40 {
content.push(Line::from(vec![
Span::styled("j/k", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_move()),
Span::styled("Enter", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_checkout()),
Span::styled("t/Esc", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_close()),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
} else {
content.push(Line::from(vec![
Span::styled("j/k Enter t/Esc", Style::default().fg(theme::SUBTEXT0)),
Span::styled(scroll_indicator, Style::default().fg(theme::SUBTEXT0)),
]));
}
let paragraph = Paragraph::new(content).block(
Block::default()
.title(app.language.topology())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_related_files_overlay(frame: &mut Frame, app: &App) {
let Some(ref related) = app.related_files_view.cache else {
return;
};
let area = frame.area();
let (x, y, width, height) = compute_overlay_size_standard(area);
let overlay_area = Rect::new(x, y, width, height);
let inner_width = width.saturating_sub(2) as usize;
frame.render_widget(Clear, overlay_area);
let mut content = Vec::new();
let title_width = inner_width.saturating_sub(16);
let title = format!(
"Related: {}",
truncate_to_width_with_ellipsis(&related.target_file, title_width)
);
content.push(Line::from(vec![Span::styled(
title,
Style::default()
.fg(theme::SAPPHIRE)
.add_modifier(Modifier::BOLD),
)]));
content.push(Line::from(""));
let lang = app.language;
if related.related.is_empty() {
content.push(Line::from(Span::styled(
if inner_width >= 40 {
lang.no_related_files()
} else {
lang.no_related_files_short()
},
Style::default().fg(theme::SUBTEXT0),
)));
} else {
if inner_width >= 35 {
content.push(Line::from(Span::styled(
lang.files_changed_together(),
Style::default().fg(theme::SUBTEXT0),
)));
content.push(Line::from(""));
}
let visible_lines = (height as usize).saturating_sub(6);
let start = app.related_files_view.nav.scroll_offset;
let end = (start + visible_lines).min(related.related.len());
let file_width = inner_width.saturating_sub(12);
for (i, (file, count)) in related
.related
.iter()
.enumerate()
.skip(start)
.take(end - start)
{
let is_selected = i == app.related_files_view.nav.selected_index;
let marker = if is_selected { "▶" } else { " " };
let style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::TEXT)
};
let count_style = if is_selected {
Style::default().fg(theme::YELLOW)
} else {
Style::default().fg(theme::SUBTEXT0)
};
let file_display = truncate_to_width_with_ellipsis(file, file_width);
content.push(Line::from(vec![
Span::styled(format!(" {} ", marker), style),
Span::styled(file_display, style),
Span::styled(format!(" ({})", count), count_style),
]));
}
if related.related.len() > visible_lines {
content.push(Line::from(""));
content.push(Line::from(Span::styled(
format!(
" [{}/{}]",
app.related_files_view.nav.selected_index + 1,
related.related.len()
),
Style::default().fg(theme::SUBTEXT0),
)));
}
}
let paragraph = Paragraph::new(content)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.title(app.language.related())
.style(Style::default().bg(OVERLAY_BG_COLOR)),
)
.wrap(ratatui::widgets::Wrap { trim: false });
frame.render_widget(paragraph, overlay_area);
}
pub(crate) fn render_preset_save_dialog(frame: &mut Frame, app: &App) {
let area = frame.area();
let width = 50u16.min(area.width.saturating_sub(OVERLAY_MARGIN * 2));
let height = 12u16.min(area.height.saturating_sub(OVERLAY_MARGIN * 2));
let x = (area.width.saturating_sub(width)) / 2;
let y = (area.height.saturating_sub(height)) / 2;
let dialog_area = Rect::new(x, y, width, height);
frame.render_widget(Clear, dialog_area);
let mut content = Vec::new();
let lang = app.language;
content.push(Line::from(Span::styled(
lang.save_filter_title(),
Style::default()
.fg(theme::SAPPHIRE)
.add_modifier(Modifier::BOLD),
)));
content.push(Line::from(""));
let filter_display = truncate_to_width_with_ellipsis(&app.filter_text, width as usize - 12);
content.push(Line::from(vec![
Span::styled(lang.filter_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(filter_display, Style::default().fg(theme::YELLOW)),
]));
content.push(Line::from(""));
content.push(Line::from(Span::styled(
lang.select_slot(),
Style::default().fg(theme::TEXT),
)));
for slot in 1..=5 {
let is_selected = slot == app.preset_save_slot;
let existing = app.filter_presets.get(slot);
let marker = if is_selected { "▶ " } else { " " };
let slot_style = if is_selected {
Style::default()
.fg(theme::YELLOW)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::TEXT)
};
let slot_info = if let Some(preset) = existing {
let name = truncate_to_width_with_ellipsis(&preset.name, 20);
format!("[{}] {} ({})", slot, name, lang.overwrite())
} else {
format!("[{}] ({})", slot, lang.empty_slot())
};
content.push(Line::from(vec![
Span::styled(marker, slot_style),
Span::styled(slot_info, slot_style),
]));
}
content.push(Line::from(""));
let name_display = if app.preset_save_name.is_empty() {
format!("{} {}", lang.preset_label(), app.preset_save_slot)
} else {
app.preset_save_name.clone()
};
content.push(Line::from(vec![
Span::styled(lang.name_label(), Style::default().fg(theme::SUBTEXT0)),
Span::styled(name_display, Style::default().fg(theme::GREEN)),
Span::styled("_", Style::default().fg(theme::TEXT)),
]));
content.push(Line::from(""));
content.push(Line::from(vec![
Span::styled("Enter", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_save()),
Span::styled("Esc", Style::default().fg(theme::YELLOW)),
Span::raw(lang.hint_cancel()),
]));
let paragraph = Paragraph::new(content).block(
Block::default()
.title(app.language.save_filter_preset())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(paragraph, dialog_area);
}
pub(crate) fn render_quick_action_overlay(frame: &mut Frame, app: &App) {
let area = frame.area();
let desired_width = if area.width < 40 { area.width } else { 46 };
let desired_height = (QuickAction::all().len() as u16 + 6).min(area.height);
let overlay_area = centered_overlay_rect(area, desired_width, desired_height);
if overlay_area.width < 3 || overlay_area.height < 3 {
return;
}
frame.render_widget(Clear, overlay_area);
let inner_width = overlay_area.width.saturating_sub(4) as usize;
let mut items = Vec::new();
for (idx, action) in QuickAction::all().iter().enumerate() {
let marker = if idx == app.quick_action_selected_index {
"›"
} else {
" "
};
let label = format!("{}{}. {}", marker, idx + 1, action.title(app.language));
let text = truncate_to_width_with_ellipsis(&label, inner_width.max(8));
let style = if idx == app.quick_action_selected_index {
Style::default()
.fg(theme::SAPPHIRE)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::TEXT)
};
items.push(ListItem::new(Line::from(Span::styled(text, style))));
}
let list = List::new(items).block(
Block::default()
.title(app.language.quick_actions())
.borders(Borders::ALL)
.border_style(Style::default().fg(theme::LAVENDER))
.style(Style::default().bg(OVERLAY_BG_COLOR)),
);
frame.render_widget(list, overlay_area);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
#[test]
fn test_render_quick_action_overlay_all_14_items() {
let backend = TestBackend::new(60, 30);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new();
app.start_quick_action_view();
assert_eq!(QuickAction::all().len(), 14);
terminal
.draw(|frame| {
render_quick_action_overlay(frame, &app);
})
.unwrap();
}
}