use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::tui::app::{App, PrConfirm, PrStateFilter};
use super::super::ui::{C_WHITE, C_SUBTLE, C_GREEN, C_RED, C_YELLOW, C_CYAN, C_DIM};
pub fn render(f: &mut Frame, app: &App, area: Rect) {
let bc = app.brand_color();
let focused = !app.sidebar_focused;
let pr = &app.pr_view;
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(60),
Constraint::Percentage(40),
])
.split(area);
let filter_str = match pr.filter {
PrStateFilter::Open => "open",
PrStateFilter::Closed => "closed",
PrStateFilter::All => "all",
};
let items: Vec<ListItem> = if pr.loading {
vec![ListItem::new(Line::from(vec![
Span::styled(" loading...", Style::default().fg(C_SUBTLE)),
]))]
} else if let Some(err) = &pr.error {
let is_token = err.to_lowercase().contains("token");
let mut err_items = vec![ListItem::new(Line::from(vec![
Span::styled(" ✗ ", Style::default().fg(C_RED)),
Span::styled(
if is_token { "authentication required".to_string() } else { "error".to_string() },
Style::default().fg(C_RED).add_modifier(Modifier::BOLD)
),
]))];
for chunk in err.chars().collect::<Vec<_>>().chunks(50) {
let s: String = chunk.iter().collect();
err_items.push(ListItem::new(Line::from(vec![
Span::styled(format!(" {}", s), Style::default().fg(C_SUBTLE)),
])));
}
err_items
} else if pr.prs.is_empty() {
vec![ListItem::new(Line::from(vec![
Span::styled(" no pull requests", Style::default().fg(C_DIM)),
]))]
} else {
pr.prs.iter().enumerate().map(|(i, p)| {
let is_sel = i == pr.idx;
let style = if is_sel {
Style::default().bg(app.selected_bg()).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let prefix = if is_sel { "█ " } else { " " };
let state_color = if p.state == "open" { C_GREEN } else { C_SUBTLE };
let draft_tag = if p.draft { " [draft]" } else { "" };
let num_str = format!("#{}", p.number);
let title_color = if is_sel { C_WHITE } else { C_SUBTLE };
ListItem::new(Line::from(vec![
Span::styled(prefix, Style::default().fg(bc)),
Span::styled(num_str, Style::default().fg(state_color)),
Span::styled(" ", Style::default()),
Span::styled(p.title.clone(), Style::default().fg(title_color)),
Span::styled(draft_tag, Style::default().fg(C_DIM)),
])).style(style)
}).collect()
};
let count = pr.prs.len();
let pr_label = if pr.platform == "gitlab" { "merge requests" } else { "pull requests" };
let title = format!(" {} — {} [{}] ", pr_label, count, filter_str);
let mut list_state = ListState::default();
if !pr.prs.is_empty() { list_state.select(Some(pr.idx)); }
let list_block = Block::default()
.title(Span::styled(title,
if focused { Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD) }
else { Style::default().fg(bc) }
))
.borders(Borders::ALL).border_type(app.border_type())
.border_style(if focused { Style::default().fg(C_WHITE) } else { Style::default().fg(bc) });
f.render_stateful_widget(List::new(items).block(list_block), cols[0], &mut list_state);
let detail_block = Block::default()
.title(Span::styled(" detail ", Style::default().fg(bc)))
.borders(Borders::ALL).border_type(app.border_type())
.border_style(Style::default().fg(bc));
if let Some(p) = pr.prs.get(pr.idx) {
let state_color = if p.state == "open" { C_GREEN } else { C_SUBTLE };
let mergeable_str = match p.mergeable {
Some(true) => Span::styled("✓ mergeable", Style::default().fg(C_GREEN)),
Some(false) => Span::styled("✗ conflicts", Style::default().fg(C_RED)),
None => Span::styled("~ unknown", Style::default().fg(C_DIM)),
};
let mut lines = vec![
Line::from(vec![
Span::styled(" #", Style::default().fg(C_SUBTLE)),
Span::styled(p.number.to_string(), Style::default().fg(state_color).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(p.state.clone(), Style::default().fg(state_color)),
if p.draft { Span::styled(" draft", Style::default().fg(C_DIM)) } else { Span::raw("") },
]),
Line::from(""),
Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(p.title.clone(), Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD)),
]),
Line::from(""),
Line::from(vec![
Span::styled(" by ", Style::default().fg(C_DIM)),
Span::styled(p.author.clone(), Style::default().fg(C_CYAN)),
]),
Line::from(vec![
Span::styled(" ", Style::default().fg(C_DIM)),
Span::styled(p.head.clone(), Style::default().fg(C_YELLOW)),
Span::styled(" → ", Style::default().fg(C_DIM)),
Span::styled(p.base.clone(), Style::default().fg(C_SUBTLE)),
]),
Line::from(vec![
Span::styled(" ", Style::default()),
mergeable_str,
]),
Line::from(vec![
Span::styled(" created ", Style::default().fg(C_DIM)),
Span::styled(p.created_at.clone(), Style::default().fg(C_SUBTLE)),
]),
];
if let Some(body) = &p.body {
if !body.trim().is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" ─── description ───", Style::default().fg(C_DIM)),
]));
for l in body.lines().take(12) {
lines.push(Line::from(vec![
Span::styled(format!(" {}", l), Style::default().fg(C_SUBTLE)),
]));
}
}
}
let para = Paragraph::new(lines)
.block(detail_block)
.wrap(Wrap { trim: false });
f.render_widget(para, cols[1]);
} else {
let para = Paragraph::new(Line::from(vec![
Span::styled(" select a PR", Style::default().fg(C_DIM)),
])).block(detail_block);
f.render_widget(para, cols[1]);
}
if pr.confirm == PrConfirm::Close {
let overlay = Rect::new(
cols[0].x + 2,
cols[0].y + 2 + pr.idx.min(cols[0].height as usize - 5) as u16,
28, 3,
);
f.render_widget(Clear, overlay);
let p = Paragraph::new(Line::from(vec![
Span::styled(" close PR? ", Style::default().fg(C_RED)),
Span::styled("[y]", Style::default().fg(C_RED).add_modifier(Modifier::BOLD)),
Span::styled(" / any", Style::default().fg(C_SUBTLE)),
])).block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(C_RED)).border_type(app.border_type()));
f.render_widget(p, overlay);
}
if pr.confirm == PrConfirm::Merge {
let methods = ["merge", "squash", "rebase"];
let overlay = Rect::new(
cols[0].x + 2,
cols[0].y + 2 + pr.idx.min(cols[0].height as usize - 7) as u16,
30, 5,
);
f.render_widget(Clear, overlay);
let method_spans: Vec<Span> = methods.iter().enumerate().map(|(i, m)| {
if i == pr.merge_method {
Span::styled(format!(" [{}] ", m), Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD).bg(app.selected_bg()))
} else {
Span::styled(format!(" {} ", m), Style::default().fg(C_SUBTLE))
}
}).collect();
let lines = vec![
Line::from(vec![Span::styled(" merge method:", Style::default().fg(C_SUBTLE))]),
Line::from(method_spans),
Line::from(vec![
Span::styled(" [←→]", Style::default().fg(bc)),
Span::styled(" select ", Style::default().fg(C_SUBTLE)),
Span::styled("[Enter]", Style::default().fg(bc)),
Span::styled(" confirm", Style::default().fg(C_SUBTLE)),
]),
];
let p = Paragraph::new(lines)
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(bc)).border_type(app.border_type()));
f.render_widget(p, overlay);
}
let pr_label = if pr.platform == "gitlab" { "MR" } else { "PR" };
if matches!(pr.confirm, PrConfirm::CreateTitle | PrConfirm::CreateBase | PrConfirm::CreateDesc) {
let (step, label, extra) = match &pr.confirm {
PrConfirm::CreateTitle => (1, format!("title"), String::new()),
PrConfirm::CreateBase => (2, format!("base branch"), String::new()),
PrConfirm::CreateDesc => (3, format!("description (optional)"),
if pr.create_draft { " [draft ✓]".to_string() } else { " [Tab] draft".to_string() }),
_ => (0, String::new(), String::new()),
};
let ow = 46u16;
let oh = 5u16;
let ox = area.x + area.width.saturating_sub(ow) / 2;
let oy = area.y + area.height.saturating_sub(oh) / 2;
let overlay = Rect::new(ox, oy, ow, oh);
let cursor = format!("{}█", pr.create_input);
let lines = vec![
Line::from(vec![
Span::styled(format!(" create {} — step {}/3: ", pr_label, step), Style::default().fg(C_SUBTLE)),
Span::styled(label, Style::default().fg(C_WHITE).add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(format!(" > {}", cursor), Style::default().fg(C_CYAN)),
]),
Line::from(vec![
Span::styled(" [Enter]", Style::default().fg(bc)),
Span::styled(" next ", Style::default().fg(C_SUBTLE)),
Span::styled("[Esc]", Style::default().fg(bc)),
Span::styled(" cancel", Style::default().fg(C_SUBTLE)),
Span::styled(extra, Style::default().fg(C_YELLOW)),
]),
];
f.render_widget(Clear, overlay);
f.render_widget(
Paragraph::new(lines).block(
Block::default().borders(Borders::ALL)
.border_style(Style::default().fg(bc))
.border_type(app.border_type())
),
overlay,
);
}
if pr.ops_mode {
let current_state = pr.prs.get(pr.idx).map(|p| p.state.as_str()).unwrap_or("open");
let create_label = if pr.platform == "gitlab" { "create MR" } else { "create PR" };
let ops: &[(&str, bool)] = &[
(create_label, false),
("merge", false),
("close ⚠", true),
("checkout", false),
("open browser", false),
("refresh", false),
];
let dropdown_w = 18u16;
let dropdown_h = ops.len() as u16 + 2;
let entry_y = cols[0].y + 1 + pr.idx as u16 + 1;
let drop_y = if entry_y + dropdown_h < cols[0].y + cols[0].height {
entry_y
} else {
cols[0].y + cols[0].height - dropdown_h
};
let drop_area = Rect::new(cols[0].x + 3, drop_y, dropdown_w, dropdown_h);
let drop_items: Vec<ListItem> = ops.iter().enumerate().map(|(i, (label, danger))| {
let is_sel = i == pr.ops_idx;
let dimmed = i == 1 && current_state != "open"; let color = if dimmed { C_DIM }
else if *danger { C_RED }
else if is_sel { C_WHITE }
else { C_SUBTLE };
let prefix = if is_sel { "▶ " } else { " " };
let style = if is_sel && !dimmed {
Style::default().bg(app.selected_bg()).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(vec![
Span::styled(prefix, Style::default().fg(bc)),
Span::styled(*label, Style::default().fg(color)),
])).style(style)
}).collect();
let mut drop_state = ListState::default();
drop_state.select(Some(pr.ops_idx));
let drop_block = Block::default()
.borders(Borders::ALL).border_type(app.border_type())
.border_style(Style::default().fg(bc));
f.render_widget(Clear, drop_area);
f.render_stateful_widget(List::new(drop_items).block(drop_block), drop_area, &mut drop_state);
}
}